@narumitw/pi-sync 0.31.0 → 0.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +159 -236
- package/package.json +11 -3
- package/src/backend-factory.ts +20 -0
- package/src/command.ts +39 -39
- package/src/config-errors.ts +7 -0
- package/src/config-file.ts +64 -8
- package/src/config.ts +605 -476
- package/src/file-selection.ts +121 -140
- package/src/git-backend.ts +832 -0
- package/src/git-config.ts +136 -0
- package/src/git-runner.ts +276 -0
- package/src/git-storage.ts +288 -0
- package/src/git-ui.ts +307 -0
- package/src/manager-helpers.ts +64 -0
- package/src/manager-ui.ts +537 -542
- package/src/s3-backend.ts +498 -0
- package/src/s3-client.ts +147 -64
- package/src/s3-credentials-ui.ts +92 -0
- package/src/secret-input.ts +199 -0
- package/src/settings-management.ts +191 -277
- package/src/settings-ui.ts +94 -115
- package/src/setup-switch.ts +150 -0
- package/src/snapshot-apply.ts +13 -7
- package/src/snapshot-codec.ts +48 -0
- package/src/snapshot-transaction.ts +3 -1
- package/src/snapshot.ts +63 -32
- package/src/storage-connections-ui.ts +308 -0
- package/src/sync-backend.ts +97 -0
- package/src/sync-format.ts +31 -33
- package/src/sync-operations.ts +912 -0
- package/src/sync-policy.ts +137 -61
- package/src/sync-setups-ui.ts +177 -0
- package/src/sync-state.ts +48 -48
- package/src/sync.ts +180 -739
- package/src/types.ts +177 -53
- package/src/webdav-backend.ts +678 -0
- package/src/webdav-client.ts +390 -0
- package/src/webdav-config.ts +92 -0
- package/src/webdav-ui.ts +421 -0
- package/src/target-switch.ts +0 -119
package/src/sync-policy.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { isDeniedPath, toPosix } from "./paths.js";
|
|
3
3
|
|
|
4
|
-
export const
|
|
4
|
+
export const BUILT_IN_SYNC_ROOTS = [
|
|
5
5
|
"settings.json",
|
|
6
6
|
"keybindings.json",
|
|
7
7
|
"models.json",
|
|
@@ -13,100 +13,148 @@ export const DEFAULT_SYNC_FILES = [
|
|
|
13
13
|
"extensions",
|
|
14
14
|
] as const;
|
|
15
15
|
|
|
16
|
-
export
|
|
16
|
+
export const DEFAULT_SYNC_INCLUDE = [...BUILT_IN_SYNC_ROOTS] as const;
|
|
17
|
+
export type BuiltInSyncFile = (typeof BUILT_IN_SYNC_ROOTS)[number];
|
|
17
18
|
|
|
18
19
|
const BUILT_IN_BY_LOWER = new Map<string, BuiltInSyncFile>(
|
|
19
|
-
|
|
20
|
+
BUILT_IN_SYNC_ROOTS.map((fileName) => [fileName.toLowerCase(), fileName]),
|
|
20
21
|
);
|
|
21
22
|
const TOP_LEVEL_FILE_PATHS = new Map<string, string>(
|
|
22
|
-
|
|
23
|
+
BUILT_IN_SYNC_ROOTS.filter((fileName) => fileName.includes(".")).map((fileName) => [
|
|
23
24
|
fileName.toLowerCase(),
|
|
24
25
|
fileName,
|
|
25
26
|
]),
|
|
26
27
|
);
|
|
27
|
-
const TOP_LEVEL_FILE_NAMES = new Set<string>(TOP_LEVEL_FILE_PATHS.keys());
|
|
28
28
|
const TOP_LEVEL_DIRS = new Set<string>(
|
|
29
|
-
|
|
29
|
+
BUILT_IN_SYNC_ROOTS.filter((fileName) => !fileName.includes(".")),
|
|
30
30
|
);
|
|
31
31
|
const RESERVED_TOP_LEVEL_NAMES = new Set<string>([...TOP_LEVEL_DIRS, "sessions"]);
|
|
32
32
|
|
|
33
|
-
export function
|
|
34
|
-
if (value
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
const result:
|
|
33
|
+
export function normalizeSyncInclude(value: unknown): string[] {
|
|
34
|
+
if (!Array.isArray(value)) {
|
|
35
|
+
throw new Error("Invalid pi-sync settings: sync.include must be an array.");
|
|
36
|
+
}
|
|
37
|
+
const result: string[] = [];
|
|
38
38
|
const seen = new Set<string>();
|
|
39
39
|
for (const item of value) {
|
|
40
|
-
if (typeof item !== "string")
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
40
|
+
if (typeof item !== "string") {
|
|
41
|
+
throw new Error("Invalid pi-sync settings: sync.include items must be strings.");
|
|
42
|
+
}
|
|
43
|
+
const trimmed = item.trim();
|
|
44
|
+
const builtIn = BUILT_IN_BY_LOWER.get(trimmed.toLowerCase());
|
|
45
|
+
const normalized = builtIn ?? (trimmed.toLowerCase() === "sessions" ? "sessions" : trimmed);
|
|
46
|
+
if (!builtIn && normalized !== "sessions") validateAgentRelativeInclude(normalized);
|
|
47
|
+
const identity = normalized.toLowerCase();
|
|
48
|
+
if (seen.has(identity)) {
|
|
49
|
+
throw new Error(`Invalid pi-sync settings: duplicate sync.include item: ${item}`);
|
|
50
|
+
}
|
|
51
|
+
for (const previous of seen) {
|
|
52
|
+
if (identity.startsWith(`${previous}/`) || previous.startsWith(`${identity}/`)) {
|
|
53
|
+
throw new Error(
|
|
54
|
+
`Invalid pi-sync settings: overlapping sync.include items are ambiguous: ${item}`,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
seen.add(identity);
|
|
59
|
+
result.push(normalized);
|
|
47
60
|
}
|
|
48
61
|
return result;
|
|
49
62
|
}
|
|
50
63
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
64
|
+
function validateAgentRelativeInclude(value: string) {
|
|
65
|
+
const normalized = toPosix(value);
|
|
66
|
+
const topLevel = normalized.split("/")[0]?.toLowerCase();
|
|
67
|
+
if (
|
|
68
|
+
!normalized ||
|
|
69
|
+
normalized === "." ||
|
|
70
|
+
normalized === ".." ||
|
|
71
|
+
normalized.startsWith("../") ||
|
|
72
|
+
path.posix.isAbsolute(normalized) ||
|
|
73
|
+
normalized.includes("\\") ||
|
|
74
|
+
path.posix.normalize(normalized) !== normalized ||
|
|
75
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: Include paths cannot contain controls.
|
|
76
|
+
/[\u0000-\u001f\u007f-\u009f]/u.test(normalized)
|
|
77
|
+
) {
|
|
78
|
+
throw new Error(
|
|
79
|
+
`Invalid pi-sync settings: sync.include item must be a safe agent-relative path: ${value}`,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
if (isDeniedPath(normalized)) {
|
|
83
|
+
throw new Error(`Invalid pi-sync settings: ${value} cannot be synced.`);
|
|
84
|
+
}
|
|
85
|
+
if (topLevel && RESERVED_TOP_LEVEL_NAMES.has(topLevel)) {
|
|
86
|
+
throw new Error(
|
|
87
|
+
`Invalid pi-sync settings: use the canonical ${topLevel} root instead of a nested sync.include path.`,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function syncIncludeSelection(value: unknown) {
|
|
93
|
+
const include = normalizeSyncInclude(value);
|
|
94
|
+
const builtIns = include.filter((item): item is BuiltInSyncFile =>
|
|
95
|
+
BUILT_IN_BY_LOWER.has(item.toLowerCase()),
|
|
96
|
+
);
|
|
97
|
+
const custom = include.filter(
|
|
98
|
+
(item) => item !== "sessions" && !BUILT_IN_BY_LOWER.has(item.toLowerCase()),
|
|
99
|
+
);
|
|
100
|
+
return { include, builtIns, custom, sessions: include.includes("sessions") };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function customIncludePathsByLower(value: unknown) {
|
|
104
|
+
return new Map(
|
|
105
|
+
syncIncludeSelection(value).custom.map((relativePath) => [
|
|
106
|
+
relativePath.toLowerCase(),
|
|
107
|
+
relativePath,
|
|
108
|
+
]),
|
|
109
|
+
);
|
|
75
110
|
}
|
|
76
111
|
|
|
77
|
-
export
|
|
78
|
-
|
|
112
|
+
export interface SyncSelectionConfig {
|
|
113
|
+
include?: unknown;
|
|
114
|
+
syncFiles?: unknown;
|
|
115
|
+
syncSessions?: boolean;
|
|
116
|
+
extraFiles?: unknown;
|
|
79
117
|
}
|
|
80
118
|
|
|
81
|
-
export function
|
|
82
|
-
return
|
|
119
|
+
export function includeFromSelectionConfig(config: SyncSelectionConfig) {
|
|
120
|
+
if (config.include !== undefined) return normalizeSyncInclude(config.include);
|
|
121
|
+
return [
|
|
122
|
+
...normalizeSyncFiles(config.syncFiles),
|
|
123
|
+
...normalizeExtraFiles(config.extraFiles),
|
|
124
|
+
...(config.syncSessions ? ["sessions"] : []),
|
|
125
|
+
];
|
|
83
126
|
}
|
|
84
127
|
|
|
85
128
|
export function isConfiguredSnapshotPath(
|
|
86
129
|
relativePath: string,
|
|
87
|
-
config:
|
|
88
|
-
|
|
130
|
+
config: SyncSelectionConfig,
|
|
131
|
+
_legacyExtraFiles?: Set<string>,
|
|
89
132
|
) {
|
|
90
133
|
const normalized = toPosix(relativePath);
|
|
91
|
-
|
|
92
|
-
|
|
134
|
+
const selection = syncIncludeSelection(includeFromSelectionConfig(config));
|
|
135
|
+
if (normalized.startsWith("sessions/")) return selection.sessions;
|
|
136
|
+
const lower = normalized.toLowerCase();
|
|
93
137
|
if (!normalized.includes("/")) {
|
|
94
|
-
const lower = normalized.toLowerCase();
|
|
95
138
|
const builtIn = BUILT_IN_BY_LOWER.get(lower);
|
|
96
|
-
|
|
139
|
+
if (builtIn) return selection.builtIns.includes(builtIn) && !TOP_LEVEL_DIRS.has(builtIn);
|
|
97
140
|
}
|
|
98
141
|
const topLevel = normalized.slice(0, normalized.indexOf("/"));
|
|
99
|
-
|
|
142
|
+
if (selection.builtIns.includes(topLevel as BuiltInSyncFile) && TOP_LEVEL_DIRS.has(topLevel)) {
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
return selection.custom.some((candidate) => {
|
|
146
|
+
const candidateLower = candidate.toLowerCase();
|
|
147
|
+
return lower === candidateLower || lower.startsWith(`${candidateLower}/`);
|
|
148
|
+
});
|
|
100
149
|
}
|
|
101
150
|
|
|
102
151
|
export function canonicalSnapshotPathForConfig(
|
|
103
152
|
relativePath: string,
|
|
104
|
-
|
|
153
|
+
includePaths: Map<string, string>,
|
|
105
154
|
) {
|
|
106
155
|
const normalized = toPosix(relativePath);
|
|
107
|
-
if (normalized.includes("/")) return normalized;
|
|
108
156
|
const lower = normalized.toLowerCase();
|
|
109
|
-
return TOP_LEVEL_FILE_PATHS.get(lower) ??
|
|
157
|
+
return TOP_LEVEL_FILE_PATHS.get(lower) ?? includePaths.get(lower) ?? normalized;
|
|
110
158
|
}
|
|
111
159
|
|
|
112
160
|
export function isPreservableUnmanagedSnapshotPath(relativePath: string) {
|
|
@@ -115,15 +163,43 @@ export function isPreservableUnmanagedSnapshotPath(relativePath: string) {
|
|
|
115
163
|
if (normalized.startsWith("sessions/")) return normalized.endsWith(".jsonl");
|
|
116
164
|
if (!normalized.includes("/")) {
|
|
117
165
|
const lower = normalized.toLowerCase();
|
|
118
|
-
return
|
|
166
|
+
return TOP_LEVEL_FILE_PATHS.has(lower) || !RESERVED_TOP_LEVEL_NAMES.has(lower);
|
|
119
167
|
}
|
|
120
|
-
return
|
|
168
|
+
return true;
|
|
121
169
|
}
|
|
122
170
|
|
|
123
|
-
export function
|
|
124
|
-
|
|
171
|
+
export function isSafeCustomIncludePath(relativePath: string) {
|
|
172
|
+
try {
|
|
173
|
+
validateAgentRelativeInclude(relativePath);
|
|
174
|
+
return true;
|
|
175
|
+
} catch {
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
125
178
|
}
|
|
126
179
|
|
|
127
180
|
export function isBuiltInTopLevelFile(fileName: string) {
|
|
128
|
-
return
|
|
181
|
+
return TOP_LEVEL_FILE_PATHS.has(path.posix.basename(fileName).toLowerCase());
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Compatibility projection for backend-neutral output while callers move to sync.include. */
|
|
185
|
+
export function normalizeSyncFiles(value: unknown): BuiltInSyncFile[] {
|
|
186
|
+
if (value === undefined) return [...DEFAULT_SYNC_INCLUDE];
|
|
187
|
+
if (Array.isArray(value)) {
|
|
188
|
+
return normalizeSyncInclude(value).filter((item): item is BuiltInSyncFile =>
|
|
189
|
+
BUILT_IN_BY_LOWER.has(item.toLowerCase()),
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
throw new Error("Invalid pi-sync settings: expected an include array.");
|
|
129
193
|
}
|
|
194
|
+
|
|
195
|
+
/** Compatibility projection for old rendering helpers; v3 persistence never writes this split field. */
|
|
196
|
+
export function normalizeExtraFiles(value: unknown) {
|
|
197
|
+
if (!Array.isArray(value)) return [];
|
|
198
|
+
return value.filter(
|
|
199
|
+
(item): item is string => typeof item === "string" && isSafeCustomIncludePath(item),
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export const extraFilePathsByLower = customIncludePathsByLower;
|
|
204
|
+
export const isSafeExtraFileName = isSafeCustomIncludePath;
|
|
205
|
+
export const selectedSyncFileSet = (value: unknown) => new Set(normalizeSyncFiles(value));
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { loadConfig, readLocalConfigObject } from "./config.js";
|
|
3
|
+
import { errorMessage, ownRecord, safeTerminalText } from "./manager-helpers.js";
|
|
4
|
+
import { syncIncludeSelection } from "./sync-policy.js";
|
|
5
|
+
|
|
6
|
+
const BACK = "Back";
|
|
7
|
+
|
|
8
|
+
export async function countValidSyncSetups(
|
|
9
|
+
setups: Record<string, unknown> | undefined,
|
|
10
|
+
signal?: AbortSignal,
|
|
11
|
+
) {
|
|
12
|
+
let count = 0;
|
|
13
|
+
for (const name of Object.keys(setups ?? {})) {
|
|
14
|
+
throwIfAborted(signal);
|
|
15
|
+
let valid = false;
|
|
16
|
+
try {
|
|
17
|
+
await loadConfig(name);
|
|
18
|
+
valid = true;
|
|
19
|
+
} catch {
|
|
20
|
+
// Invalid setups stay visible in management but are not switchable.
|
|
21
|
+
}
|
|
22
|
+
throwIfAborted(signal);
|
|
23
|
+
if (valid) count += 1;
|
|
24
|
+
}
|
|
25
|
+
return count;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function throwIfAborted(signal?: AbortSignal) {
|
|
29
|
+
if (!signal?.aborted) return;
|
|
30
|
+
throw signal.reason instanceof Error
|
|
31
|
+
? signal.reason
|
|
32
|
+
: new DOMException("The operation was aborted", "AbortError");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type SyncSetupActions = {
|
|
36
|
+
add(signal?: AbortSignal): Promise<void>;
|
|
37
|
+
edit(name: string, signal?: AbortSignal): Promise<void>;
|
|
38
|
+
makeCurrent(name: string, signal?: AbortSignal): Promise<"exit" | undefined>;
|
|
39
|
+
remove(name: string, signal?: AbortSignal): Promise<void>;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export async function showSyncSetups(
|
|
43
|
+
ctx: ExtensionCommandContext,
|
|
44
|
+
actions: SyncSetupActions,
|
|
45
|
+
signal?: AbortSignal,
|
|
46
|
+
): Promise<"exit" | undefined> {
|
|
47
|
+
while (!signal?.aborted) {
|
|
48
|
+
const raw = await readLocalConfigObject();
|
|
49
|
+
throwIfAborted(signal);
|
|
50
|
+
const setups = ownRecord(raw?.syncSetups) ?? {};
|
|
51
|
+
const active = typeof raw?.activeSyncSetup === "string" ? raw.activeSyncSetup : undefined;
|
|
52
|
+
const labels = new Map<string, string>();
|
|
53
|
+
for (const name of Object.keys(setups).sort((left, right) => left.localeCompare(right))) {
|
|
54
|
+
const label = `${safeTerminalText(name)}${name === active ? " (current)" : ""}`;
|
|
55
|
+
labels.set(label, name);
|
|
56
|
+
}
|
|
57
|
+
const selected = await ctx.ui.select(
|
|
58
|
+
"Sync setups",
|
|
59
|
+
["Add sync setup", ...labels.keys(), BACK],
|
|
60
|
+
{ signal },
|
|
61
|
+
);
|
|
62
|
+
if (signal?.aborted || !selected || selected === BACK) return;
|
|
63
|
+
if (selected === "Add sync setup") {
|
|
64
|
+
try {
|
|
65
|
+
await actions.add(signal);
|
|
66
|
+
} catch (error) {
|
|
67
|
+
if (signal?.aborted) return;
|
|
68
|
+
ctx.ui.notify(
|
|
69
|
+
`Sync setup was not added: ${menuErrorMessage(error)} Retry from Add sync setup.`,
|
|
70
|
+
"error",
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
const name = labels.get(selected);
|
|
76
|
+
if (!name) continue;
|
|
77
|
+
const result = await showSyncSetupDetail(ctx, name, actions, signal);
|
|
78
|
+
if (result === "exit") return "exit";
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function showSyncSetupDetail(
|
|
83
|
+
ctx: ExtensionCommandContext,
|
|
84
|
+
name: string,
|
|
85
|
+
actions: SyncSetupActions,
|
|
86
|
+
signal?: AbortSignal,
|
|
87
|
+
): Promise<"exit" | undefined> {
|
|
88
|
+
while (!signal?.aborted) {
|
|
89
|
+
const raw = await readLocalConfigObject();
|
|
90
|
+
throwIfAborted(signal);
|
|
91
|
+
const setups = ownRecord(raw?.syncSetups) ?? {};
|
|
92
|
+
const setup = ownRecord(setups[name]);
|
|
93
|
+
if (!setup) {
|
|
94
|
+
ctx.ui.notify(`Sync setup “${safeTerminalText(name)}” no longer exists.`, "warning");
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const active = typeof raw?.activeSyncSetup === "string" ? raw.activeSyncSetup : undefined;
|
|
98
|
+
const setupCount = Object.keys(setups).length;
|
|
99
|
+
const isCurrent = name === active;
|
|
100
|
+
let detail: string[];
|
|
101
|
+
let valid = true;
|
|
102
|
+
try {
|
|
103
|
+
const config = await loadConfig(name);
|
|
104
|
+
throwIfAborted(signal);
|
|
105
|
+
const selection = syncIncludeSelection(config.include);
|
|
106
|
+
detail = [
|
|
107
|
+
`Status: ${isCurrent ? "Current" : "Not current"}`,
|
|
108
|
+
`Storage connection: ${safeTerminalText(config.connectionName)}`,
|
|
109
|
+
`Endpoint: ${storageEndpoint(config)}`,
|
|
110
|
+
`Storage location: ${storageLocation(config)}`,
|
|
111
|
+
`Included content: ${selection.builtIns.length} built-in groups · ${selection.custom.length} extra paths`,
|
|
112
|
+
`Sessions: ${selection.sessions ? "On — privacy-sensitive" : "Off"}`,
|
|
113
|
+
`Automatic sync: ${config.automatic ? "On" : "Off"}`,
|
|
114
|
+
];
|
|
115
|
+
} catch (error) {
|
|
116
|
+
valid = false;
|
|
117
|
+
detail = [
|
|
118
|
+
`Status: Invalid${isCurrent ? " current setup" : ""}`,
|
|
119
|
+
`Reason: ${menuErrorMessage(error)}`,
|
|
120
|
+
"Make current and sync are unavailable until this setup is repaired.",
|
|
121
|
+
];
|
|
122
|
+
}
|
|
123
|
+
const removeUnavailable = isCurrent && setupCount > 1;
|
|
124
|
+
if (removeUnavailable) detail.push("Remove unavailable: switch to another setup first.");
|
|
125
|
+
const selected = await ctx.ui.select(
|
|
126
|
+
[`Sync setup “${safeTerminalText(name)}”`, "", ...detail].join("\n"),
|
|
127
|
+
[
|
|
128
|
+
...(!isCurrent && valid ? ["Make current…"] : []),
|
|
129
|
+
"Edit sync setup…",
|
|
130
|
+
...(removeUnavailable ? [] : ["Remove sync setup…"]),
|
|
131
|
+
BACK,
|
|
132
|
+
],
|
|
133
|
+
{ signal },
|
|
134
|
+
);
|
|
135
|
+
if (signal?.aborted || !selected || selected === BACK) return;
|
|
136
|
+
try {
|
|
137
|
+
if (selected === "Make current…") return actions.makeCurrent(name, signal);
|
|
138
|
+
if (selected === "Edit sync setup…") await actions.edit(name, signal);
|
|
139
|
+
else if (selected === "Remove sync setup…") {
|
|
140
|
+
await actions.remove(name, signal);
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
} catch (error) {
|
|
144
|
+
if (signal?.aborted) return;
|
|
145
|
+
ctx.ui.notify(
|
|
146
|
+
`Sync setup “${safeTerminalText(name)}” was not changed: ${menuErrorMessage(error)} Reopen it and retry.`,
|
|
147
|
+
"error",
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function menuErrorMessage(error: unknown) {
|
|
154
|
+
return safeTerminalText(errorMessage(error));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function storageEndpoint(config: Awaited<ReturnType<typeof loadConfig>>) {
|
|
158
|
+
switch (config.backend.type) {
|
|
159
|
+
case "s3":
|
|
160
|
+
return safeTerminalText(config.backend.profile.endpoint);
|
|
161
|
+
case "git":
|
|
162
|
+
return safeTerminalText(config.backend.profile.remote);
|
|
163
|
+
case "webdav":
|
|
164
|
+
return safeTerminalText(config.backend.profile.url);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function storageLocation(config: Awaited<ReturnType<typeof loadConfig>>) {
|
|
169
|
+
switch (config.backend.type) {
|
|
170
|
+
case "s3":
|
|
171
|
+
return safeTerminalText(`${config.backend.destination.bucket}/${config.storagePath}`);
|
|
172
|
+
case "git":
|
|
173
|
+
return safeTerminalText(`Git · ${config.backend.destination.branch}:${config.storagePath}`);
|
|
174
|
+
case "webdav":
|
|
175
|
+
return safeTerminalText(`WebDAV · ${config.storagePath}`);
|
|
176
|
+
}
|
|
177
|
+
}
|
package/src/sync-state.ts
CHANGED
|
@@ -5,28 +5,43 @@ import {
|
|
|
5
5
|
isConfiguredSnapshotPath,
|
|
6
6
|
isSessionPath,
|
|
7
7
|
} from "./snapshot.js";
|
|
8
|
-
import {
|
|
9
|
-
import
|
|
8
|
+
import type { RemoteHead } from "./sync-backend.js";
|
|
9
|
+
import {
|
|
10
|
+
customIncludePathsByLower,
|
|
11
|
+
includeFromSelectionConfig,
|
|
12
|
+
normalizeSyncInclude,
|
|
13
|
+
type SyncSelectionConfig,
|
|
14
|
+
} from "./sync-policy.js";
|
|
15
|
+
import type { Snapshot, SyncState } from "./types.js";
|
|
16
|
+
|
|
17
|
+
type SyncPolicyConfig = SyncSelectionConfig;
|
|
10
18
|
|
|
11
|
-
export function hasLocalChanges(local: Snapshot, state: SyncState, config:
|
|
19
|
+
export function hasLocalChanges(local: Snapshot, state: SyncState, config: SyncPolicyConfig) {
|
|
12
20
|
return !sameHashes(fileHashMap(local), stateHashMapForConfig(state, config));
|
|
13
21
|
}
|
|
14
22
|
|
|
15
23
|
export function remoteChangedSinceState(
|
|
16
|
-
|
|
24
|
+
head: RemoteHead | undefined,
|
|
17
25
|
state: SyncState,
|
|
18
|
-
config:
|
|
26
|
+
config: SyncPolicyConfig,
|
|
27
|
+
sameRevision: (left: string, right: string) => boolean,
|
|
19
28
|
) {
|
|
20
|
-
if (
|
|
21
|
-
if (
|
|
22
|
-
if (
|
|
23
|
-
|
|
29
|
+
if (!head) return Boolean(state.lastAppliedSnapshot);
|
|
30
|
+
if (head.snapshotId !== state.lastAppliedSnapshot) return true;
|
|
31
|
+
if (state.lastRemoteRevision && !sameRevision(head.revision, state.lastRemoteRevision))
|
|
32
|
+
return true;
|
|
33
|
+
if (syncIncludeChanged(state, config)) return true;
|
|
34
|
+
return (
|
|
35
|
+
includeFromSelectionConfig(config).includes("sessions") &&
|
|
36
|
+
!state.include?.includes("sessions") &&
|
|
37
|
+
head.syncSessions
|
|
38
|
+
);
|
|
24
39
|
}
|
|
25
40
|
|
|
26
41
|
export function hasRemoteChanges(
|
|
27
42
|
remote: Snapshot,
|
|
28
43
|
state: SyncState,
|
|
29
|
-
config:
|
|
44
|
+
config: SyncPolicyConfig,
|
|
30
45
|
ignoredPaths = new Set<string>(),
|
|
31
46
|
) {
|
|
32
47
|
if (remote.id === state.lastAppliedSnapshot && !syncPolicyChanged(state, config)) return false;
|
|
@@ -40,9 +55,7 @@ export function hasRemoteChanges(
|
|
|
40
55
|
|
|
41
56
|
export function sameHashes(left: Record<string, string>, right: Record<string, string>) {
|
|
42
57
|
const keys = new Set([...Object.keys(left), ...Object.keys(right)]);
|
|
43
|
-
for (const key of keys)
|
|
44
|
-
if (left[key] !== right[key]) return false;
|
|
45
|
-
}
|
|
58
|
+
for (const key of keys) if (left[key] !== right[key]) return false;
|
|
46
59
|
return true;
|
|
47
60
|
}
|
|
48
61
|
|
|
@@ -50,23 +63,19 @@ export function fileHashMap(snapshot: Snapshot) {
|
|
|
50
63
|
return Object.fromEntries(snapshot.files.map((file) => [file.path, file.sha256]));
|
|
51
64
|
}
|
|
52
65
|
|
|
53
|
-
function stateHashMapForConfig(
|
|
54
|
-
|
|
55
|
-
config: Pick<SyncConfig, "syncFiles" | "syncSessions" | "extraFiles">,
|
|
56
|
-
) {
|
|
57
|
-
const extraFilePaths = extraFilePathsByLower(config.extraFiles);
|
|
58
|
-
const extraFiles = new Set(extraFilePaths.keys());
|
|
66
|
+
function stateHashMapForConfig(state: SyncState, config: SyncPolicyConfig) {
|
|
67
|
+
const includePaths = customIncludePathsByLower(includeFromSelectionConfig(config));
|
|
59
68
|
return Object.fromEntries(
|
|
60
69
|
Object.entries(state.lastFileHashes)
|
|
61
|
-
.filter(([filePath]) => isConfiguredSnapshotPath(filePath, config
|
|
62
|
-
.map(([filePath, hash]) => [canonicalSnapshotPathForConfig(filePath,
|
|
70
|
+
.filter(([filePath]) => isConfiguredSnapshotPath(filePath, config))
|
|
71
|
+
.map(([filePath, hash]) => [canonicalSnapshotPathForConfig(filePath, includePaths), hash]),
|
|
63
72
|
);
|
|
64
73
|
}
|
|
65
74
|
|
|
66
75
|
export function snapshotHashesMatchState(
|
|
67
76
|
snapshot: Snapshot,
|
|
68
77
|
state: SyncState,
|
|
69
|
-
config:
|
|
78
|
+
config: SyncPolicyConfig,
|
|
70
79
|
ignoredPaths = new Set<string>(),
|
|
71
80
|
) {
|
|
72
81
|
return sameHashes(
|
|
@@ -88,41 +97,32 @@ function withoutHashPaths(hashes: Record<string, string>, ignoredPaths: Set<stri
|
|
|
88
97
|
);
|
|
89
98
|
}
|
|
90
99
|
|
|
91
|
-
export function syncPolicyChanged(
|
|
92
|
-
state
|
|
93
|
-
config: Pick<SyncConfig, "syncFiles" | "syncSessions" | "extraFiles">,
|
|
94
|
-
) {
|
|
95
|
-
return (
|
|
96
|
-
syncFilesChanged(state, config) ||
|
|
97
|
-
(state.syncSessions ?? false) !== config.syncSessions ||
|
|
98
|
-
extraFilesChanged(state, config)
|
|
99
|
-
);
|
|
100
|
+
export function syncPolicyChanged(state: SyncState, config: SyncPolicyConfig) {
|
|
101
|
+
return syncIncludeChanged(state, config);
|
|
100
102
|
}
|
|
101
103
|
|
|
102
104
|
export function shouldRefreshSyncedState(
|
|
103
105
|
remote: Snapshot,
|
|
106
|
+
head: RemoteHead | undefined,
|
|
104
107
|
state: SyncState,
|
|
105
|
-
config:
|
|
108
|
+
config: SyncPolicyConfig,
|
|
109
|
+
sameRevision: (left: string, right: string) => boolean,
|
|
106
110
|
) {
|
|
107
|
-
return
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
function extraFilesChanged(state: SyncState, config: Pick<SyncConfig, "extraFiles">) {
|
|
115
|
-
return !sameStringSet(
|
|
116
|
-
normalizeExtraFiles(state.extraFiles),
|
|
117
|
-
normalizeExtraFiles(config.extraFiles),
|
|
111
|
+
return (
|
|
112
|
+
remote.id !== state.lastAppliedSnapshot ||
|
|
113
|
+
Boolean(
|
|
114
|
+
head && (!state.lastRemoteRevision || !sameRevision(head.revision, state.lastRemoteRevision)),
|
|
115
|
+
) ||
|
|
116
|
+
syncPolicyChanged(state, config)
|
|
118
117
|
);
|
|
119
118
|
}
|
|
120
119
|
|
|
121
|
-
function
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
120
|
+
function syncIncludeChanged(state: SyncState, config: SyncPolicyConfig) {
|
|
121
|
+
const stored = state.include
|
|
122
|
+
? normalizeSyncInclude(state.include)
|
|
123
|
+
: includeFromSelectionConfig(state);
|
|
124
|
+
const current = includeFromSelectionConfig(config);
|
|
125
|
+
return stored.length !== current.length || stored.some((item, index) => item !== current[index]);
|
|
126
126
|
}
|
|
127
127
|
|
|
128
128
|
export function settingsHashMap(snapshot: Snapshot) {
|