improve tab sync
This commit is contained in:
@@ -489,11 +489,13 @@ class TabDao extends DatabaseAccessor<TabDatabase> with $TabDaoMixin {
|
|||||||
// hierarchical views.
|
// hierarchical views.
|
||||||
Value<String?> parentValue = const Value.absent();
|
Value<String?> parentValue = const Value.absent();
|
||||||
Value<String?> containerValue = const Value.absent();
|
Value<String?> containerValue = const Value.absent();
|
||||||
|
Value<TabSource> rootSource = const Value.absent();
|
||||||
switch (parentChange) {
|
switch (parentChange) {
|
||||||
case TabParentUnchanged():
|
case TabParentUnchanged():
|
||||||
break;
|
break;
|
||||||
case TabParentDetach():
|
case TabParentDetach():
|
||||||
parentValue = const Value(null);
|
parentValue = const Value(null);
|
||||||
|
rootSource = const Value(TabSource.manual);
|
||||||
case TabParentToSpecific(:final parentTabId):
|
case TabParentToSpecific(:final parentTabId):
|
||||||
final parent =
|
final parent =
|
||||||
anchors[parentTabId] ??
|
anchors[parentTabId] ??
|
||||||
@@ -501,6 +503,7 @@ class TabDao extends DatabaseAccessor<TabDatabase> with $TabDaoMixin {
|
|||||||
if (parent != null) {
|
if (parent != null) {
|
||||||
parentValue = Value(parentTabId);
|
parentValue = Value(parentTabId);
|
||||||
containerValue = Value(parent.containerId);
|
containerValue = Value(parent.containerId);
|
||||||
|
rootSource = const Value(TabSource.manual);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -510,6 +513,7 @@ class TabDao extends DatabaseAccessor<TabDatabase> with $TabDaoMixin {
|
|||||||
batch.update(
|
batch.update(
|
||||||
db.tab,
|
db.tab,
|
||||||
TabCompanion(
|
TabCompanion(
|
||||||
|
source: isRoot ? rootSource : const Value.absent(),
|
||||||
orderKey: Value(orderKeys[i]),
|
orderKey: Value(orderKeys[i]),
|
||||||
// parent_id change applies only to the moving root;
|
// parent_id change applies only to the moving root;
|
||||||
// descendants keep their existing parent_id pointers.
|
// descendants keep their existing parent_id pointers.
|
||||||
@@ -592,9 +596,12 @@ class TabDao extends DatabaseAccessor<TabDatabase> with $TabDaoMixin {
|
|||||||
if (newParentId == null) {
|
if (newParentId == null) {
|
||||||
// Detach: keep existing order_keys. The row becomes a root in its
|
// Detach: keep existing order_keys. The row becomes a root in its
|
||||||
// current slot and the subtree under it stays intact.
|
// current slot and the subtree under it stays intact.
|
||||||
await _updateByIdStatement(
|
await _updateByIdStatement(tabId).write(
|
||||||
tabId,
|
const TabCompanion(
|
||||||
).write(const TabCompanion(parentId: Value(null)));
|
source: Value(TabSource.manual),
|
||||||
|
parentId: Value(null),
|
||||||
|
),
|
||||||
|
);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -662,6 +669,7 @@ class TabDao extends DatabaseAccessor<TabDatabase> with $TabDaoMixin {
|
|||||||
batch.update(
|
batch.update(
|
||||||
db.tab,
|
db.tab,
|
||||||
TabCompanion(
|
TabCompanion(
|
||||||
|
source: const Value(TabSource.manual),
|
||||||
parentId: Value(newParentId),
|
parentId: Value(newParentId),
|
||||||
orderKey: Value(orderKeys[0]),
|
orderKey: Value(orderKeys[0]),
|
||||||
),
|
),
|
||||||
@@ -728,6 +736,7 @@ class TabDao extends DatabaseAccessor<TabDatabase> with $TabDaoMixin {
|
|||||||
batch.update(
|
batch.update(
|
||||||
db.tab,
|
db.tab,
|
||||||
TabCompanion(
|
TabCompanion(
|
||||||
|
source: const Value(TabSource.manual),
|
||||||
parentId: Value(parent.parentId),
|
parentId: Value(parent.parentId),
|
||||||
orderKey: Value(parent.orderKey),
|
orderKey: Value(parent.orderKey),
|
||||||
),
|
),
|
||||||
@@ -736,6 +745,7 @@ class TabDao extends DatabaseAccessor<TabDatabase> with $TabDaoMixin {
|
|||||||
batch.update(
|
batch.update(
|
||||||
db.tab,
|
db.tab,
|
||||||
TabCompanion(
|
TabCompanion(
|
||||||
|
source: const Value(TabSource.manual),
|
||||||
parentId: Value(childId),
|
parentId: Value(childId),
|
||||||
orderKey: Value(newParentOrderKey),
|
orderKey: Value(newParentOrderKey),
|
||||||
),
|
),
|
||||||
@@ -1062,14 +1072,29 @@ class TabDao extends DatabaseAccessor<TabDatabase> with $TabDaoMixin {
|
|||||||
Map<String, TabState> next,
|
Map<String, TabState> next,
|
||||||
) {
|
) {
|
||||||
return db.transaction(() async {
|
return db.transaction(() async {
|
||||||
|
// Gecko exposes a parentId in content-state events, but local DB tab
|
||||||
|
// hierarchy becomes authoritative once a row has a DB parent or has
|
||||||
|
// been manually created/reparented. Only use Gecko parent data to seed
|
||||||
|
// engine-event rows that have not been claimed by local hierarchy yet.
|
||||||
|
// Keep retrying while the row remains unclaimed so out-of-order tab-list
|
||||||
|
// inserts can seed the parent later without needing another parentId
|
||||||
|
// change from Gecko.
|
||||||
|
final parentSyncEligibleIds = next.isEmpty
|
||||||
|
? const <String>{}
|
||||||
|
: {
|
||||||
|
for (final tab in await (select(
|
||||||
|
db.tab,
|
||||||
|
)..where((t) => t.id.isIn(next.keys))).get())
|
||||||
|
if (tab.source != TabSource.manual && tab.parentId == null)
|
||||||
|
tab.id,
|
||||||
|
};
|
||||||
|
|
||||||
// Collect parent IDs that need database validation
|
// Collect parent IDs that need database validation
|
||||||
final parentIdsToValidate = <String>{};
|
final parentIdsToValidate = <String>{};
|
||||||
final validatedParentIds = <String, String?>{};
|
final validatedParentIds = <String, String?>{};
|
||||||
|
|
||||||
for (final state in next.values) {
|
for (final state in next.values) {
|
||||||
final previousState = previous?[state.id];
|
if (parentSyncEligibleIds.contains(state.id) &&
|
||||||
|
|
||||||
if (previousState?.parentId != state.parentId &&
|
|
||||||
state.parentId != null) {
|
state.parentId != null) {
|
||||||
if (next.containsKey(state.parentId)) {
|
if (next.containsKey(state.parentId)) {
|
||||||
// Parent exists in current state
|
// Parent exists in current state
|
||||||
@@ -1114,50 +1139,52 @@ class TabDao extends DatabaseAccessor<TabDatabase> with $TabDaoMixin {
|
|||||||
|
|
||||||
// Complete validation map
|
// Complete validation map
|
||||||
for (final state in next.values) {
|
for (final state in next.values) {
|
||||||
final previousState = previous?[state.id];
|
if (!parentSyncEligibleIds.contains(state.id) ||
|
||||||
|
validatedParentIds.containsKey(state.id) ||
|
||||||
|
state.parentId == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (previousState?.parentId != state.parentId) {
|
// This parent ID needed database validation
|
||||||
if (!validatedParentIds.containsKey(state.id)) {
|
if (existingParentIds.contains(state.parentId)) {
|
||||||
// This parent ID needed database validation
|
validatedParentIds[state.id] = state.parentId;
|
||||||
if (state.parentId != null &&
|
|
||||||
existingParentIds.contains(state.parentId)) {
|
|
||||||
validatedParentIds[state.id] = state.parentId;
|
|
||||||
} else {
|
|
||||||
validatedParentIds[state.id] = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await batch((batch) {
|
await batch((batch) {
|
||||||
for (final state in next.values) {
|
for (final state in next.values) {
|
||||||
final previousState = previous?[state.id];
|
final previousState = previous?[state.id];
|
||||||
|
final hasParentUpdate = validatedParentIds.containsKey(state.id);
|
||||||
|
final hasContainerRepair = repairedContainerIds.containsKey(state.id);
|
||||||
|
final hasUrlChange = previousState?.url != state.url;
|
||||||
|
final hasTitleChange = previousState?.title != state.title;
|
||||||
|
final hasTabModeChange = previousState?.tabMode != state.tabMode;
|
||||||
|
|
||||||
if (previousState == null ||
|
if (hasUrlChange ||
|
||||||
previousState.url != state.url ||
|
hasTitleChange ||
|
||||||
previousState.title != state.title ||
|
hasTabModeChange ||
|
||||||
previousState.parentId != state.parentId ||
|
hasParentUpdate ||
|
||||||
previousState.tabMode != state.tabMode ||
|
hasContainerRepair) {
|
||||||
repairedContainerIds.containsKey(state.id)) {
|
|
||||||
batch.update(
|
batch.update(
|
||||||
db.tab,
|
db.tab,
|
||||||
TabCompanion(
|
TabCompanion(
|
||||||
parentId: validatedParentIds.containsKey(state.id)
|
parentId: hasParentUpdate
|
||||||
? Value(validatedParentIds[state.id])
|
? Value(validatedParentIds[state.id])
|
||||||
: const Value.absent(),
|
: const Value.absent(),
|
||||||
|
source: hasParentUpdate
|
||||||
|
? const Value(TabSource.manual)
|
||||||
|
: const Value.absent(),
|
||||||
containerId:
|
containerId:
|
||||||
repairedContainerIds[state.id].mapNotNull(Value.new) ??
|
repairedContainerIds[state.id].mapNotNull(Value.new) ??
|
||||||
const Value.absent(),
|
const Value.absent(),
|
||||||
url: (previousState?.url != state.url)
|
url: hasUrlChange ? Value(state.url) : const Value.absent(),
|
||||||
? Value(state.url)
|
title: hasTitleChange
|
||||||
: const Value.absent(),
|
|
||||||
title: (previousState?.title != state.title)
|
|
||||||
? Value(state.title)
|
? Value(state.title)
|
||||||
: const Value.absent(),
|
: const Value.absent(),
|
||||||
tabMode: (previousState?.tabMode != state.tabMode)
|
tabMode: hasTabModeChange
|
||||||
? Value(state.tabMode.toDbValue())
|
? Value(state.tabMode.toDbValue())
|
||||||
: const Value.absent(),
|
: const Value.absent(),
|
||||||
isolationContextId: (previousState?.tabMode != state.tabMode)
|
isolationContextId: hasTabModeChange
|
||||||
? Value(state.isolationContextId)
|
? Value(state.isolationContextId)
|
||||||
: const Value.absent(),
|
: const Value.absent(),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import 'package:drift/drift.dart';
|
import 'package:drift/drift.dart' show Value;
|
||||||
import 'package:drift/native.dart';
|
import 'package:drift/native.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:lexo_rank/lexo_rank.dart';
|
import 'package:lexo_rank/lexo_rank.dart';
|
||||||
import 'package:weblibre/data/database/functions/lexo_rank_functions.dart';
|
import 'package:weblibre/data/database/functions/lexo_rank_functions.dart';
|
||||||
import 'package:weblibre/data/database/functions/url_functions.dart';
|
import 'package:weblibre/data/database/functions/url_functions.dart';
|
||||||
|
import 'package:weblibre/features/geckoview/domain/entities/states/tab.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/tabs/data/database/database.dart';
|
import 'package:weblibre/features/geckoview/features/tabs/data/database/database.dart';
|
||||||
|
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_source.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
late TabDatabase db;
|
late TabDatabase db;
|
||||||
@@ -93,14 +95,216 @@ void main() {
|
|||||||
]);
|
]);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
test('content-state sync seeds parent for an unclaimed engine row', () async {
|
||||||
|
await _insertTabs(db, const [
|
||||||
|
_TabFixture('gecko-parent', source: TabSource.addedEvent),
|
||||||
|
_TabFixture('child', source: TabSource.addedEvent),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await db.tabDao.updateTabs(null, {
|
||||||
|
'child': _tabState('child', parentId: 'gecko-parent'),
|
||||||
|
});
|
||||||
|
|
||||||
|
final child = await db.tabDao.getTabDataById('child').getSingleOrNull();
|
||||||
|
expect(child, isNotNull);
|
||||||
|
expect(child!.parentId, 'gecko-parent');
|
||||||
|
expect(child.source, TabSource.manual);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'content-state sync retries an unresolved engine parent when the row arrives later',
|
||||||
|
() async {
|
||||||
|
await _insertTabs(db, const [
|
||||||
|
_TabFixture('child', source: TabSource.addedEvent),
|
||||||
|
]);
|
||||||
|
|
||||||
|
final initialState = {
|
||||||
|
'child': _tabState('child', parentId: 'late-parent'),
|
||||||
|
};
|
||||||
|
|
||||||
|
await db.tabDao.updateTabs(null, initialState);
|
||||||
|
|
||||||
|
final unresolvedChild = await db.tabDao
|
||||||
|
.getTabDataById('child')
|
||||||
|
.getSingleOrNull();
|
||||||
|
expect(unresolvedChild, isNotNull);
|
||||||
|
expect(unresolvedChild!.parentId, isNull);
|
||||||
|
expect(unresolvedChild.source, TabSource.addedEvent);
|
||||||
|
|
||||||
|
await _insertTabs(db, const [
|
||||||
|
_TabFixture('late-parent', source: TabSource.addedEvent),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await db.tabDao.updateTabs(initialState, {
|
||||||
|
'child': _tabState('child', parentId: 'late-parent'),
|
||||||
|
'late-parent': _tabState('late-parent'),
|
||||||
|
});
|
||||||
|
|
||||||
|
final resolvedChild = await db.tabDao
|
||||||
|
.getTabDataById('child')
|
||||||
|
.getSingleOrNull();
|
||||||
|
expect(resolvedChild, isNotNull);
|
||||||
|
expect(resolvedChild!.parentId, 'late-parent');
|
||||||
|
expect(resolvedChild.source, TabSource.manual);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'content-state sync ignores parent-only changes with unresolved parents',
|
||||||
|
() async {
|
||||||
|
await _insertTabs(db, const [
|
||||||
|
_TabFixture('child', source: TabSource.addedEvent),
|
||||||
|
]);
|
||||||
|
|
||||||
|
final previousState = _tabState('child');
|
||||||
|
|
||||||
|
await db.tabDao.updateTabs(
|
||||||
|
{'child': previousState},
|
||||||
|
{'child': previousState.copyWith(parentId: 'missing-parent')},
|
||||||
|
);
|
||||||
|
|
||||||
|
final child = await db.tabDao.getTabDataById('child').getSingleOrNull();
|
||||||
|
expect(child, isNotNull);
|
||||||
|
expect(child!.parentId, isNull);
|
||||||
|
expect(child.source, TabSource.addedEvent);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'content-state sync does not overwrite a locally managed parent',
|
||||||
|
() async {
|
||||||
|
await _insertTabs(db, const [
|
||||||
|
_TabFixture('local-parent'),
|
||||||
|
_TabFixture('gecko-parent', source: TabSource.addedEvent),
|
||||||
|
_TabFixture('child', parentId: 'local-parent'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await db.tabDao.updateTabs(null, {
|
||||||
|
'child': _tabState('child', parentId: 'gecko-parent'),
|
||||||
|
});
|
||||||
|
|
||||||
|
final child = await db.tabDao.getTabDataById('child').getSingleOrNull();
|
||||||
|
expect(child, isNotNull);
|
||||||
|
expect(child!.parentId, 'local-parent');
|
||||||
|
expect(child.source, TabSource.manual);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'content-state sync ignores parent-only changes for locally managed rows',
|
||||||
|
() async {
|
||||||
|
await _insertTabs(db, const [
|
||||||
|
_TabFixture('local-parent'),
|
||||||
|
_TabFixture('gecko-parent', source: TabSource.addedEvent),
|
||||||
|
_TabFixture('child', parentId: 'local-parent'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
final previousState = _tabState('child', parentId: 'local-parent');
|
||||||
|
|
||||||
|
await db.tabDao.updateTabs(
|
||||||
|
{'child': previousState},
|
||||||
|
{'child': previousState.copyWith(parentId: 'gecko-parent')},
|
||||||
|
);
|
||||||
|
|
||||||
|
final child = await db.tabDao.getTabDataById('child').getSingleOrNull();
|
||||||
|
expect(child, isNotNull);
|
||||||
|
expect(child!.parentId, 'local-parent');
|
||||||
|
expect(child.source, TabSource.manual);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'content-state sync preserves an existing parent on engine rows',
|
||||||
|
() async {
|
||||||
|
await _insertTabs(db, const [
|
||||||
|
_TabFixture('existing-parent', source: TabSource.addedEvent),
|
||||||
|
_TabFixture('gecko-parent', source: TabSource.addedEvent),
|
||||||
|
_TabFixture(
|
||||||
|
'child',
|
||||||
|
parentId: 'existing-parent',
|
||||||
|
source: TabSource.addedEvent,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await db.tabDao.updateTabs(null, {
|
||||||
|
'child': _tabState('child', parentId: 'gecko-parent'),
|
||||||
|
});
|
||||||
|
|
||||||
|
final child = await db.tabDao.getTabDataById('child').getSingleOrNull();
|
||||||
|
expect(child, isNotNull);
|
||||||
|
expect(child!.parentId, 'existing-parent');
|
||||||
|
expect(child.source, TabSource.addedEvent);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('reorder-only moves do not claim manual hierarchy authority', () async {
|
||||||
|
await _insertTabs(db, const [
|
||||||
|
_TabFixture('other'),
|
||||||
|
_TabFixture('child', source: TabSource.addedEvent),
|
||||||
|
_TabFixture('gecko-parent', source: TabSource.addedEvent),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await db.tabDao.reorderTabs(
|
||||||
|
movingTabIds: const ['child'],
|
||||||
|
previousTabId: null,
|
||||||
|
nextTabId: 'other',
|
||||||
|
);
|
||||||
|
|
||||||
|
final reorderedChild = await db.tabDao
|
||||||
|
.getTabDataById('child')
|
||||||
|
.getSingleOrNull();
|
||||||
|
expect(reorderedChild, isNotNull);
|
||||||
|
expect(reorderedChild!.source, TabSource.addedEvent);
|
||||||
|
expect(reorderedChild.parentId, isNull);
|
||||||
|
|
||||||
|
await db.tabDao.updateTabs(null, {
|
||||||
|
'child': _tabState('child', parentId: 'gecko-parent'),
|
||||||
|
});
|
||||||
|
|
||||||
|
final seededChild = await db.tabDao
|
||||||
|
.getTabDataById('child')
|
||||||
|
.getSingleOrNull();
|
||||||
|
expect(seededChild, isNotNull);
|
||||||
|
expect(seededChild!.parentId, 'gecko-parent');
|
||||||
|
expect(seededChild.source, TabSource.manual);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tab-list sync preserves an existing local parent', () async {
|
||||||
|
await _insertTabs(db, const [
|
||||||
|
_TabFixture('parent'),
|
||||||
|
_TabFixture('child', parentId: 'parent'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await db.tabDao.syncTabs(retainTabIds: const ['parent', 'child']);
|
||||||
|
|
||||||
|
final child = await db.tabDao.getTabDataById('child').getSingleOrNull();
|
||||||
|
expect(child, isNotNull);
|
||||||
|
expect(child!.parentId, 'parent');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deleting a parent rewires children to the grandparent', () async {
|
||||||
|
await _insertTabs(db, const [
|
||||||
|
_TabFixture('grandparent'),
|
||||||
|
_TabFixture('parent', parentId: 'grandparent'),
|
||||||
|
_TabFixture('child', parentId: 'parent'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await db.customStatement("DELETE FROM tab WHERE id = 'parent'");
|
||||||
|
|
||||||
|
final child = await db.tabDao.getTabDataById('child').getSingleOrNull();
|
||||||
|
expect(child, isNotNull);
|
||||||
|
expect(child!.parentId, 'grandparent');
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _insertTabs(TabDatabase db, List<_TabFixture> tabs) async {
|
Future<void> _insertTabs(TabDatabase db, List<_TabFixture> tabs) async {
|
||||||
final orderKeys = _spacedOrderKeys(tabs.length);
|
final orderKeys = _spacedOrderKeys(tabs.length);
|
||||||
|
|
||||||
for (final (index, tab) in tabs.indexed) {
|
for (final (index, tab) in tabs.indexed) {
|
||||||
await db.tabDao.upsertTabTransactional(
|
await db.tabDao.insertTab(
|
||||||
() async => tab.id,
|
tab.id,
|
||||||
|
source: tab.source,
|
||||||
parentId: Value(tab.parentId),
|
parentId: Value(tab.parentId),
|
||||||
orderKey: Value(orderKeys[index]),
|
orderKey: Value(orderKeys[index]),
|
||||||
);
|
);
|
||||||
@@ -128,6 +332,15 @@ List<String> _spacedOrderKeys(int count) {
|
|||||||
class _TabFixture {
|
class _TabFixture {
|
||||||
final String id;
|
final String id;
|
||||||
final String? parentId;
|
final String? parentId;
|
||||||
|
final TabSource source;
|
||||||
|
|
||||||
const _TabFixture(this.id, {this.parentId});
|
const _TabFixture(this.id, {this.parentId, this.source = TabSource.manual});
|
||||||
|
}
|
||||||
|
|
||||||
|
TabState _tabState(String id, {String? parentId}) {
|
||||||
|
return TabState.$default(id).copyWith(
|
||||||
|
parentId: parentId,
|
||||||
|
url: Uri.parse('https://$id.example/'),
|
||||||
|
title: id,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user