mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-09-09 19:31:57 +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
+8
-2
@@ -356,8 +356,14 @@ export type ImportSourceResource = {
|
||||
importSourceId: string;
|
||||
sourceKey: string;
|
||||
modelType: string;
|
||||
modelId: string;
|
||||
snapshot: string;
|
||||
/**
|
||||
* `None` once the user has decided not to import this key
|
||||
*/
|
||||
modelId?: string;
|
||||
/**
|
||||
* Hash of the resource as last applied or decided from the source, if one was recorded
|
||||
*/
|
||||
contentHash?: string;
|
||||
};
|
||||
|
||||
export type InheritedBoolSetting = { enabled?: boolean; value: boolean };
|
||||
|
||||
Generated
+73
-23
@@ -1,7 +1,21 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { Environment, Folder, GrpcRequest, HttpRequest, WebsocketRequest, Workspace } from "./gen_models";
|
||||
import type {
|
||||
Environment,
|
||||
Folder,
|
||||
GrpcRequest,
|
||||
HttpRequest,
|
||||
WebsocketRequest,
|
||||
Workspace,
|
||||
} from "./gen_models";
|
||||
|
||||
export type BatchUpsertResult = { workspaces: Array<Workspace>, environments: Array<Environment>, folders: Array<Folder>, httpRequests: Array<HttpRequest>, grpcRequests: Array<GrpcRequest>, websocketRequests: Array<WebsocketRequest>, };
|
||||
export type BatchUpsertResult = {
|
||||
workspaces: Array<Workspace>;
|
||||
environments: Array<Environment>;
|
||||
folders: Array<Folder>;
|
||||
httpRequests: Array<HttpRequest>;
|
||||
grpcRequests: Array<GrpcRequest>;
|
||||
websocketRequests: Array<WebsocketRequest>;
|
||||
};
|
||||
|
||||
export type ImportConflictResolution = "keep_mine" | "take_source";
|
||||
|
||||
@@ -11,38 +25,74 @@ export type ImportConflictResolution = "keep_mine" | "take_source";
|
||||
* The destination workspace and optional folder IDs are captured in the plan so the preview describes
|
||||
* the exact destination that confirmation will use.
|
||||
*/
|
||||
export type ImportDestination = { "type": "new_workspace" } | { "type": "existing_workspace", workspaceId: string, folderId?: string, };
|
||||
export type ImportDestination =
|
||||
| { type: "new_workspace" }
|
||||
| { type: "existing_workspace"; workspaceId: string; folderId?: string };
|
||||
|
||||
/**
|
||||
* Where an import's contents came from, used to link the committed workspace back to it.
|
||||
*/
|
||||
export type ImportOrigin = {
|
||||
/**
|
||||
* The absolute file path or URL the contents were read from.
|
||||
*/
|
||||
origin: string, label: string, };
|
||||
export type ImportOrigin = {
|
||||
/**
|
||||
* The absolute file path or URL the contents were read from.
|
||||
*/
|
||||
origin: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export type ImportPlan = { importer: string, destination: ImportDestination, resources: BatchUpsertResult, warnings: Array<ImportPlanWarning>,
|
||||
/**
|
||||
* Stable source key for every model in `resources`, keyed by its planned ID.
|
||||
*/
|
||||
sourceKeys: { [key in string]?: string },
|
||||
/**
|
||||
* One entry per plannable resource; commit applies only the selected ones.
|
||||
*/
|
||||
items: Array<ImportPlanItem>, origin?: ImportOrigin, };
|
||||
export type ImportPlan = {
|
||||
importer: string;
|
||||
destination: ImportDestination;
|
||||
resources: BatchUpsertResult;
|
||||
warnings: Array<ImportPlanWarning>;
|
||||
/**
|
||||
* Stable source key for every model in `resources`, keyed by its planned ID.
|
||||
*/
|
||||
sourceKeys: { [key in string]?: string };
|
||||
/**
|
||||
* One entry per plannable resource; commit applies only the selected ones.
|
||||
*/
|
||||
items: Array<ImportPlanItem>;
|
||||
origin?: ImportOrigin;
|
||||
};
|
||||
|
||||
export type ImportPlanAction = "create" | "update" | "delete" | "unchanged" | "keep_local" | "conflict";
|
||||
export type ImportPlanAction =
|
||||
| "create"
|
||||
| "update"
|
||||
| "delete"
|
||||
| "unchanged"
|
||||
| "keep_local"
|
||||
| "conflict"
|
||||
| "not_imported";
|
||||
|
||||
export type ImportPlanItem = {
|
||||
action: ImportPlanAction;
|
||||
model: ImportResourceType;
|
||||
modelId: string;
|
||||
name: string;
|
||||
/**
|
||||
* Planned parent folder ID for incoming resources; current parent for deletions.
|
||||
*/
|
||||
parentId?: string;
|
||||
selected: boolean;
|
||||
resolution?: ImportConflictResolution;
|
||||
reason?: ImportPlanReason;
|
||||
};
|
||||
|
||||
export type ImportPlanItem = { action: ImportPlanAction, model: ImportResourceType, modelId: string, name: string,
|
||||
/**
|
||||
* Planned parent folder ID for incoming resources; current parent for deletions.
|
||||
* Extra context for an action that would otherwise be indistinguishable from its plain form.
|
||||
*/
|
||||
parentId?: string, selected: boolean, resolution?: ImportConflictResolution, };
|
||||
export type ImportPlanReason = "moved_into_not_imported_folder";
|
||||
|
||||
export type ImportPlanWarning = { title: string, detail: string, };
|
||||
export type ImportPlanWarning = { title: string; detail: string };
|
||||
|
||||
/**
|
||||
* The model types an import plan can contain.
|
||||
*/
|
||||
export type ImportResourceType = "environment" | "folder" | "grpc_request" | "http_request" | "websocket_request" | "workspace";
|
||||
export type ImportResourceType =
|
||||
| "environment"
|
||||
| "folder"
|
||||
| "grpc_request"
|
||||
| "http_request"
|
||||
| "websocket_request"
|
||||
| "workspace";
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
-- Replace the per-resource snapshot with a content hash, and let a row exist without a model
|
||||
-- so a resource the user chose not to import can be remembered.
|
||||
CREATE TABLE import_source_resources_new
|
||||
(
|
||||
model TEXT DEFAULT 'import_source_resource' NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
import_source_id TEXT NOT NULL,
|
||||
source_key TEXT NOT NULL,
|
||||
model_type TEXT NOT NULL,
|
||||
model_id TEXT,
|
||||
content_hash TEXT,
|
||||
PRIMARY KEY (import_source_id, source_key)
|
||||
);
|
||||
|
||||
INSERT INTO import_source_resources_new (model, created_at, updated_at, import_source_id,
|
||||
source_key, model_type, model_id, content_hash)
|
||||
SELECT model, created_at, updated_at, import_source_id, source_key, model_type, model_id, NULL
|
||||
FROM import_source_resources;
|
||||
|
||||
DROP TABLE import_source_resources;
|
||||
|
||||
ALTER TABLE import_source_resources_new
|
||||
RENAME TO import_source_resources;
|
||||
@@ -3118,8 +3118,12 @@ pub struct ImportSourceResource {
|
||||
pub import_source_id: String,
|
||||
pub source_key: String,
|
||||
pub model_type: String,
|
||||
pub model_id: String,
|
||||
pub snapshot: String,
|
||||
/// `None` once the user has decided not to import this key
|
||||
#[ts(optional)]
|
||||
pub model_id: Option<String>,
|
||||
/// Hash of the resource as last applied or decided from the source, if one was recorded
|
||||
#[ts(optional)]
|
||||
pub content_hash: Option<String>,
|
||||
}
|
||||
|
||||
impl<'s> TryFrom<&Row<'s>> for ImportSourceResource {
|
||||
@@ -3134,7 +3138,7 @@ impl<'s> TryFrom<&Row<'s>> for ImportSourceResource {
|
||||
source_key: r.get("source_key")?,
|
||||
model_type: r.get("model_type")?,
|
||||
model_id: r.get("model_id")?,
|
||||
snapshot: r.get("snapshot")?,
|
||||
content_hash: r.get("content_hash")?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ impl<'a> ClientDb<'a> {
|
||||
ImportSourceResourceIden::SourceKey,
|
||||
ImportSourceResourceIden::ModelType,
|
||||
ImportSourceResourceIden::ModelId,
|
||||
ImportSourceResourceIden::Snapshot,
|
||||
ImportSourceResourceIden::ContentHash,
|
||||
])
|
||||
.values_panic([
|
||||
CurrentTimestamp.into(),
|
||||
@@ -42,8 +42,8 @@ impl<'a> ClientDb<'a> {
|
||||
resource.import_source_id.as_str().into(),
|
||||
resource.source_key.as_str().into(),
|
||||
resource.model_type.as_str().into(),
|
||||
resource.model_id.as_str().into(),
|
||||
resource.snapshot.as_str().into(),
|
||||
resource.model_id.clone().into(),
|
||||
resource.content_hash.clone().into(),
|
||||
])
|
||||
.on_conflict(
|
||||
OnConflict::columns([
|
||||
@@ -54,7 +54,7 @@ impl<'a> ClientDb<'a> {
|
||||
ImportSourceResourceIden::UpdatedAt,
|
||||
ImportSourceResourceIden::ModelType,
|
||||
ImportSourceResourceIden::ModelId,
|
||||
ImportSourceResourceIden::Snapshot,
|
||||
ImportSourceResourceIden::ContentHash,
|
||||
])
|
||||
.to_owned(),
|
||||
)
|
||||
|
||||
@@ -169,6 +169,16 @@ pub enum ImportPlanAction {
|
||||
Unchanged,
|
||||
KeepLocal,
|
||||
Conflict,
|
||||
/// Present in the source but previously turned down; selecting it imports it again
|
||||
NotImported,
|
||||
}
|
||||
|
||||
/// Extra context for an action that would otherwise be indistinguishable from its plain form.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, TS)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[ts(export, export_to = "gen_util.ts")]
|
||||
pub enum ImportPlanReason {
|
||||
MovedIntoNotImportedFolder,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, TS)]
|
||||
@@ -193,6 +203,8 @@ pub struct ImportPlanItem {
|
||||
pub selected: bool,
|
||||
#[ts(optional)]
|
||||
pub resolution: Option<ImportConflictResolution>,
|
||||
#[ts(optional)]
|
||||
pub reason: Option<ImportPlanReason>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, TS)]
|
||||
|
||||
Reference in New Issue
Block a user