mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-09-09 11:21:50 +02:00
feat(import): remember the user's import selection
Yaak now remembers, per item of a linked import source, whether the user wants it. A mapping row that still points at a model means wanted, so re-imports merge into it; a row with no model behind it means not wanted, so re-imports leave it alone. Wanted-ness is structural rather than a flag: deleting the model locally, or turning the item down in the preview, is what makes it not wanted. - import_source_resources drops `snapshot` for a nullable `content_hash` and lets `model_id` be NULL. The migration keeps every beta row's key to model mapping and starts hashes empty; until a hash is recorded, a difference can't be attributed to either side, so the item is offered once as a conflict that keeps local changes. - comparable() ignores sortPriority. Importers number it from source order, so inserting one operation used to plan a fake update for everything after it. The trade is that pure reorders don't propagate. - Keys the user turned down come back as unchecked "not imported" rows instead of being re-offered as new resources or resurrected. Checking one imports it under the same source key and pulls in the folders it needs. - An imported resource the source moves into a folder that isn't imported is offered as a deletion, with a tooltip pointing at the folder. - Plan-time source resolution binds by source-key overlap instead of by path, so a renamed or moved file merges into what it created and heals the stored origin. Several sources sharing keys never guess-merge: the plan says so and imports everything as new. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
d2d4b80a09
commit
bd932ce85f
@@ -261,13 +261,20 @@ function LoadedImportDataDialog({
|
||||
const itemTree = useMemo(() => buildItemTree(items), [items]);
|
||||
|
||||
// A folder row's checkbox aggregates its subtree the way the git commit tree does: creates and
|
||||
// updates toggle together, while removals only ever cascade beneath a removed folder.
|
||||
// updates toggle together, while removals only ever cascade beneath a removed folder. Checking
|
||||
// anything also brings back the folders it needs to live in.
|
||||
const toggleNode = (node: CheckboxTreeNode<ImportPlanItem>, checked: boolean) => {
|
||||
const targets = new Set(
|
||||
collectItems(node)
|
||||
.filter((i) => togglesWith(node.data, i))
|
||||
.map((i) => i.modelId),
|
||||
);
|
||||
if (checked) {
|
||||
const byId = new Map(items.map((i) => [i.modelId, i]));
|
||||
for (const ancestor of ancestorsOf(node.data, byId)) {
|
||||
if (ancestor.action === "not_imported") targets.add(ancestor.modelId);
|
||||
}
|
||||
}
|
||||
setItems((prev) => prev.map((i) => (targets.has(i.modelId) ? { ...i, selected: checked } : i)));
|
||||
};
|
||||
|
||||
@@ -283,19 +290,17 @@ function LoadedImportDataDialog({
|
||||
const disabled = new Set<string>();
|
||||
const byId = new Map(items.map((i) => [i.modelId, i]));
|
||||
for (const item of items) {
|
||||
const seen = new Set<string>();
|
||||
let parentId = item.parentId;
|
||||
while (parentId != null && !seen.has(parentId)) {
|
||||
seen.add(parentId);
|
||||
const parent = byId.get(parentId);
|
||||
if (parent == null || parent.model !== "folder") break;
|
||||
if (parent.action === "create" && !parent.selected && item.action !== "delete") {
|
||||
for (const parent of ancestorsOf(item, byId)) {
|
||||
if (parent.model !== "folder") break;
|
||||
const missing =
|
||||
(parent.action === "create" || parent.action === "not_imported") && !parent.selected;
|
||||
// A not-imported row stays checkable: checking it brings its folders back with it
|
||||
if (missing && item.action !== "delete" && item.action !== "not_imported") {
|
||||
disabled.add(item.modelId);
|
||||
}
|
||||
if (parent.action === "delete" && parent.selected && item.action === "delete") {
|
||||
disabled.add(item.modelId);
|
||||
}
|
||||
parentId = parent.parentId;
|
||||
}
|
||||
}
|
||||
return disabled;
|
||||
@@ -358,6 +363,7 @@ function LoadedImportDataDialog({
|
||||
checked={nodeCheckedStatus}
|
||||
onCheck={toggleNode}
|
||||
isCheckboxDisabled={(n) => disabledIds.has(n.key)}
|
||||
isCollapsedByDefault={(n) => n.data.action === "not_imported"}
|
||||
isRelevant={(n) => n.data.model === "workspace" || n.data.action !== "unchanged"}
|
||||
renderRow={(n) => <ImportTreeRow item={n.data} onResolveConflict={resolveConflict} />}
|
||||
/>
|
||||
@@ -589,6 +595,7 @@ function ImportTreeRow({
|
||||
item.action === "update" && "text-info",
|
||||
item.action === "delete" && "text-danger",
|
||||
item.action === "keep_local" && item.selected && "text-warning",
|
||||
item.action === "not_imported" && "text-text-subtlest",
|
||||
)}
|
||||
>
|
||||
{actionLabel(item)}
|
||||
@@ -610,6 +617,8 @@ function actionLabel(item: ImportPlanItem): string | null {
|
||||
return "removed";
|
||||
case "keep_local":
|
||||
return "edited";
|
||||
case "not_imported":
|
||||
return "not imported";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
@@ -622,16 +631,35 @@ function actionHelp(item: ImportPlanItem): string | null {
|
||||
case "update":
|
||||
return "Changed since the last import";
|
||||
case "delete":
|
||||
return "Deleted since the last import";
|
||||
return item.reason === "moved_into_not_imported_folder"
|
||||
? "Moved into a folder that isn't imported. Import that folder instead to follow the move"
|
||||
: "Deleted since the last import";
|
||||
case "keep_local":
|
||||
return "Local edits made since the last import. Importing will revert them if checked";
|
||||
case "conflict":
|
||||
return "Changed both here and in the file since the last import";
|
||||
case "not_imported":
|
||||
return "In the file, but not imported. Check it to import it";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Every plan item above `item`, nearest first. */
|
||||
function ancestorsOf(item: ImportPlanItem, byId: Map<string, ImportPlanItem>): ImportPlanItem[] {
|
||||
const ancestors: ImportPlanItem[] = [];
|
||||
const seen = new Set<string>();
|
||||
let parentId = item.parentId;
|
||||
while (parentId != null && !seen.has(parentId)) {
|
||||
seen.add(parentId);
|
||||
const parent = byId.get(parentId);
|
||||
if (parent == null) break;
|
||||
ancestors.push(parent);
|
||||
parentId = parent.parentId;
|
||||
}
|
||||
return ancestors;
|
||||
}
|
||||
|
||||
function buildItemTree(items: ImportPlanItem[]): CheckboxTreeNode<ImportPlanItem>[] {
|
||||
const byId = new Map(items.map((i) => [i.modelId, i]));
|
||||
const childrenOf = new Map<string, ImportPlanItem[]>();
|
||||
@@ -678,7 +706,7 @@ function togglesWith(root: ImportPlanItem, item: ImportPlanItem): boolean {
|
||||
if (item.action === "keep_local") {
|
||||
return root.modelId === item.modelId && item.model !== "folder";
|
||||
}
|
||||
return item.action === "create" || item.action === "update";
|
||||
return item.action === "create" || item.action === "update" || item.action === "not_imported";
|
||||
}
|
||||
|
||||
function nodeCheckedStatus(
|
||||
|
||||
@@ -21,6 +21,8 @@ interface Props<T> {
|
||||
isCheckboxDisabled?: (node: CheckboxTreeNode<T>) => boolean;
|
||||
/** An irrelevant row is hidden unless one of its descendants is relevant */
|
||||
isRelevant: (node: CheckboxTreeNode<T>) => boolean;
|
||||
/** A node that starts collapsed, so a large subtree doesn't crowd out the rest */
|
||||
isCollapsedByDefault?: (node: CheckboxTreeNode<T>) => boolean;
|
||||
renderRow: (node: CheckboxTreeNode<T>) => ReactNode;
|
||||
onSelectRow?: (node: CheckboxTreeNode<T>) => void;
|
||||
canSelectRow?: (node: CheckboxTreeNode<T>) => boolean;
|
||||
@@ -29,7 +31,9 @@ interface Props<T> {
|
||||
|
||||
export function CheckboxTree<T>(props: Props<T>) {
|
||||
const { node, depth = 0 } = props;
|
||||
const [collapsed, setCollapsed] = useState<boolean>(false);
|
||||
const [collapsed, setCollapsed] = useState<boolean>(
|
||||
() => props.isCollapsedByDefault?.(node) ?? false,
|
||||
);
|
||||
if (!hasRelevantNode(node, props.isRelevant)) return null;
|
||||
|
||||
const checked = props.checked(node);
|
||||
|
||||
Reference in New Issue
Block a user