pi-studio 0.9.52 → 0.9.54
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/CHANGELOG.md +32 -0
- package/README.md +15 -9
- package/ROADMAP.md +15 -2
- package/client/studio-client.js +1387 -245
- package/client/studio-preview-resource-helpers.js +61 -12
- package/client/studio.css +180 -3
- package/index.ts +991 -166
- package/package.json +1 -1
- package/shared/studio-disk-revisions.js +558 -0
- package/shared/studio-file-watcher.js +181 -0
- package/shared/studio-resource-grants.js +157 -0
- package/shared/studio-side-question-context.js +17 -4
- package/shared/studio-workspace-state.js +4 -0
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { watchFile, unwatchFile } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
createStudioDiskRevision,
|
|
5
|
+
normalizeStudioDiskRevision,
|
|
6
|
+
readStudioDiskFileSnapshot,
|
|
7
|
+
} from "./studio-disk-revisions.js";
|
|
8
|
+
|
|
9
|
+
export const STUDIO_FILE_WATCH_INTERVAL_MS = 300;
|
|
10
|
+
export const STUDIO_FILE_WATCH_DEBOUNCE_MS = 150;
|
|
11
|
+
|
|
12
|
+
function errorMessage(error) {
|
|
13
|
+
return error instanceof Error ? error.message : String(error);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function normalizeComparablePath(filePath) {
|
|
17
|
+
const normalized = resolve(filePath);
|
|
18
|
+
return process.platform === "win32" ? normalized.toLowerCase() : normalized;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function pathsMatch(left, right) {
|
|
22
|
+
return normalizeComparablePath(left) === normalizeComparablePath(right);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function createStudioFileWatcher(options) {
|
|
26
|
+
const filePath = typeof options?.filePath === "string" ? options.filePath.trim() : "";
|
|
27
|
+
if (!filePath) throw new Error("Missing watched file path.");
|
|
28
|
+
const canonicalPath = resolve(filePath);
|
|
29
|
+
const requestedIntervalMs = Number(options?.intervalMs);
|
|
30
|
+
const requestedDebounceMs = Number(options?.debounceMs);
|
|
31
|
+
const intervalMs = Math.max(20, Math.floor(Number.isFinite(requestedIntervalMs) ? requestedIntervalMs : STUDIO_FILE_WATCH_INTERVAL_MS));
|
|
32
|
+
const debounceMs = Math.max(0, Math.floor(Number.isFinite(requestedDebounceMs) ? requestedDebounceMs : STUDIO_FILE_WATCH_DEBOUNCE_MS));
|
|
33
|
+
const readSnapshot = typeof options?.readSnapshot === "function" ? options.readSnapshot : readStudioDiskFileSnapshot;
|
|
34
|
+
const onUpdate = typeof options?.onUpdate === "function" ? options.onUpdate : () => undefined;
|
|
35
|
+
const onError = typeof options?.onError === "function" ? options.onError : () => undefined;
|
|
36
|
+
const onRecovered = typeof options?.onRecovered === "function" ? options.onRecovered : () => undefined;
|
|
37
|
+
let lastRevision = normalizeStudioDiskRevision(options?.initialRevision);
|
|
38
|
+
let lastError = null;
|
|
39
|
+
let generation = 0;
|
|
40
|
+
let debounceTimer = null;
|
|
41
|
+
let errorRetryTimer = null;
|
|
42
|
+
let startupReconcileTimer = null;
|
|
43
|
+
let refreshInFlight = null;
|
|
44
|
+
let refreshQueued = false;
|
|
45
|
+
let closed = false;
|
|
46
|
+
|
|
47
|
+
const readStableSnapshot = async () => {
|
|
48
|
+
const snapshot = await readSnapshot(canonicalPath);
|
|
49
|
+
if (!snapshot || typeof snapshot !== "object" || !pathsMatch(snapshot.path, canonicalPath)) {
|
|
50
|
+
throw new Error("The watched file location now resolves somewhere else.");
|
|
51
|
+
}
|
|
52
|
+
const revision = normalizeStudioDiskRevision(snapshot.revision)
|
|
53
|
+
|| createStudioDiskRevision(snapshot.buffer ?? snapshot.text ?? "");
|
|
54
|
+
const text = typeof snapshot.text === "string"
|
|
55
|
+
? snapshot.text
|
|
56
|
+
: Buffer.from(snapshot.buffer ?? "").toString("utf8");
|
|
57
|
+
return { ...snapshot, path: canonicalPath, text, revision };
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const scheduleErrorRetry = () => {
|
|
61
|
+
if (closed || errorRetryTimer) return;
|
|
62
|
+
errorRetryTimer = setTimeout(() => {
|
|
63
|
+
errorRetryTimer = null;
|
|
64
|
+
void refresh().catch(() => {
|
|
65
|
+
// onError owns watcher failures; never create an unhandled rejection.
|
|
66
|
+
});
|
|
67
|
+
}, intervalMs);
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const performRefresh = async () => {
|
|
71
|
+
try {
|
|
72
|
+
const snapshot = await readStableSnapshot();
|
|
73
|
+
if (closed) return false;
|
|
74
|
+
if (errorRetryTimer) {
|
|
75
|
+
clearTimeout(errorRetryTimer);
|
|
76
|
+
errorRetryTimer = null;
|
|
77
|
+
}
|
|
78
|
+
const recovered = lastError !== null;
|
|
79
|
+
const changed = snapshot.revision !== lastRevision;
|
|
80
|
+
if (changed) {
|
|
81
|
+
const nextGeneration = generation + 1;
|
|
82
|
+
await onUpdate(snapshot, { generation: nextGeneration, recovered });
|
|
83
|
+
if (closed) return false;
|
|
84
|
+
generation = nextGeneration;
|
|
85
|
+
lastRevision = snapshot.revision;
|
|
86
|
+
// Reconcile after publication in case a save landed while an earlier
|
|
87
|
+
// read or client update was in flight and filesystem events coalesced.
|
|
88
|
+
refreshQueued = true;
|
|
89
|
+
}
|
|
90
|
+
if (recovered) {
|
|
91
|
+
lastError = null;
|
|
92
|
+
await onRecovered(snapshot, { generation, changed });
|
|
93
|
+
}
|
|
94
|
+
return changed;
|
|
95
|
+
} catch (error) {
|
|
96
|
+
if (closed) return false;
|
|
97
|
+
const message = errorMessage(error);
|
|
98
|
+
if (message !== lastError) {
|
|
99
|
+
lastError = message;
|
|
100
|
+
await onError(error, { generation: generation + 1, lastRevision });
|
|
101
|
+
}
|
|
102
|
+
scheduleErrorRetry();
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const refresh = () => {
|
|
108
|
+
if (closed) return Promise.resolve(false);
|
|
109
|
+
refreshQueued = true;
|
|
110
|
+
if (refreshInFlight) return refreshInFlight;
|
|
111
|
+
let changed = false;
|
|
112
|
+
const loop = (async () => {
|
|
113
|
+
while (!closed && refreshQueued) {
|
|
114
|
+
refreshQueued = false;
|
|
115
|
+
changed = await performRefresh() || changed;
|
|
116
|
+
}
|
|
117
|
+
return changed;
|
|
118
|
+
})();
|
|
119
|
+
const tracked = loop.finally(() => {
|
|
120
|
+
if (refreshInFlight === tracked) refreshInFlight = null;
|
|
121
|
+
});
|
|
122
|
+
refreshInFlight = tracked;
|
|
123
|
+
return tracked;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const schedule = () => {
|
|
127
|
+
if (closed) return;
|
|
128
|
+
if (debounceTimer) clearTimeout(debounceTimer);
|
|
129
|
+
debounceTimer = setTimeout(() => {
|
|
130
|
+
debounceTimer = null;
|
|
131
|
+
void refresh().catch(() => {
|
|
132
|
+
// onError owns watcher failures; never create an unhandled rejection.
|
|
133
|
+
});
|
|
134
|
+
}, debounceMs);
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const listener = () => schedule();
|
|
138
|
+
watchFile(canonicalPath, { interval: intervalMs }, listener);
|
|
139
|
+
// StatWatcher establishes its first comparison baseline asynchronously. Reconcile
|
|
140
|
+
// once after that window so an atomic replacement immediately after subscribe
|
|
141
|
+
// cannot become the unseen baseline and wait forever for another filesystem event.
|
|
142
|
+
startupReconcileTimer = setTimeout(() => {
|
|
143
|
+
startupReconcileTimer = null;
|
|
144
|
+
void refresh().catch(() => {
|
|
145
|
+
// onError owns watcher failures; never create an unhandled rejection.
|
|
146
|
+
});
|
|
147
|
+
}, intervalMs);
|
|
148
|
+
|
|
149
|
+
return Object.freeze({
|
|
150
|
+
filePath: canonicalPath,
|
|
151
|
+
refresh,
|
|
152
|
+
async close() {
|
|
153
|
+
if (closed) return;
|
|
154
|
+
closed = true;
|
|
155
|
+
refreshQueued = false;
|
|
156
|
+
if (debounceTimer) {
|
|
157
|
+
clearTimeout(debounceTimer);
|
|
158
|
+
debounceTimer = null;
|
|
159
|
+
}
|
|
160
|
+
if (errorRetryTimer) {
|
|
161
|
+
clearTimeout(errorRetryTimer);
|
|
162
|
+
errorRetryTimer = null;
|
|
163
|
+
}
|
|
164
|
+
if (startupReconcileTimer) {
|
|
165
|
+
clearTimeout(startupReconcileTimer);
|
|
166
|
+
startupReconcileTimer = null;
|
|
167
|
+
}
|
|
168
|
+
unwatchFile(canonicalPath, listener);
|
|
169
|
+
if (refreshInFlight) await refreshInFlight.catch(() => undefined);
|
|
170
|
+
},
|
|
171
|
+
get revision() {
|
|
172
|
+
return lastRevision;
|
|
173
|
+
},
|
|
174
|
+
get error() {
|
|
175
|
+
return lastError;
|
|
176
|
+
},
|
|
177
|
+
get generation() {
|
|
178
|
+
return generation;
|
|
179
|
+
},
|
|
180
|
+
});
|
|
181
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { realpathSync, statSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
4
|
+
|
|
5
|
+
export const STUDIO_RESOURCE_GRANT_MAX_ENTRIES = 128;
|
|
6
|
+
|
|
7
|
+
function expandHome(pathInput) {
|
|
8
|
+
const value = String(pathInput || "").trim();
|
|
9
|
+
if (value === "~") return homedir();
|
|
10
|
+
if (value.startsWith("~/") || value.startsWith("~\\")) return resolve(homedir(), value.slice(2));
|
|
11
|
+
return value;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function resolveGrantInput(pathInput, fallbackCwd) {
|
|
15
|
+
const raw = expandHome(pathInput);
|
|
16
|
+
if (!raw) throw new Error("Missing Studio resource grant path.");
|
|
17
|
+
if (/\0/.test(raw)) throw new Error("Invalid Studio resource grant path.");
|
|
18
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(raw) && !/^[a-z]:[\\/]/i.test(raw)) {
|
|
19
|
+
throw new Error("Studio resource grants require a local path.");
|
|
20
|
+
}
|
|
21
|
+
return isAbsolute(raw) ? raw : resolve(fallbackCwd, raw);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function canonicalExistingPath(pathInput, fallbackCwd, expectedKind) {
|
|
25
|
+
const resolvedPath = resolveGrantInput(pathInput, fallbackCwd);
|
|
26
|
+
const canonicalPath = realpathSync(resolvedPath);
|
|
27
|
+
const stats = statSync(canonicalPath);
|
|
28
|
+
if (expectedKind === "directory" && !stats.isDirectory()) {
|
|
29
|
+
throw new Error("Studio resource directory grant does not refer to a directory.");
|
|
30
|
+
}
|
|
31
|
+
if (expectedKind === "file" && !stats.isFile()) {
|
|
32
|
+
throw new Error("Studio resource file grant does not refer to a file.");
|
|
33
|
+
}
|
|
34
|
+
if (!stats.isDirectory() && !stats.isFile()) {
|
|
35
|
+
throw new Error("Studio resource grant path must refer to a file or directory.");
|
|
36
|
+
}
|
|
37
|
+
return { canonicalPath, resolvedPath, kind: stats.isDirectory() ? "directory" : "file" };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function isPathInsideOrEqualDirectory(candidatePath, directoryPath) {
|
|
41
|
+
const rel = relative(directoryPath, candidatePath);
|
|
42
|
+
return rel === "" || (!isAbsolute(rel) && rel !== ".." && !rel.startsWith(`..${sep}`));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function normalizeGrantSource(source, fallback) {
|
|
46
|
+
const value = String(source || "").trim();
|
|
47
|
+
return (value || fallback).slice(0, 100);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function copyGrant(entry) {
|
|
51
|
+
return Object.freeze({
|
|
52
|
+
kind: entry.kind,
|
|
53
|
+
path: entry.path,
|
|
54
|
+
sources: Object.freeze(Array.from(entry.sources)),
|
|
55
|
+
grantedAt: entry.grantedAt,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function createStudioResourceGrantRegistry(options = {}) {
|
|
60
|
+
const requestedMaxEntries = Number(options.maxEntries);
|
|
61
|
+
const maxEntries = Math.max(1, Math.min(
|
|
62
|
+
STUDIO_RESOURCE_GRANT_MAX_ENTRIES,
|
|
63
|
+
Number.isFinite(requestedMaxEntries) && requestedMaxEntries > 0
|
|
64
|
+
? Math.floor(requestedMaxEntries)
|
|
65
|
+
: STUDIO_RESOURCE_GRANT_MAX_ENTRIES,
|
|
66
|
+
));
|
|
67
|
+
const now = typeof options.now === "function" ? options.now : Date.now;
|
|
68
|
+
const defaultCwd = typeof options.cwd === "string" && options.cwd.trim() ? options.cwd : process.cwd();
|
|
69
|
+
const entries = new Map();
|
|
70
|
+
|
|
71
|
+
function fallbackCwd(details) {
|
|
72
|
+
return typeof details?.cwd === "string" && details.cwd.trim() ? details.cwd : defaultCwd;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function addGrant(kind, canonicalPath, source) {
|
|
76
|
+
const key = `${kind}:${canonicalPath}`;
|
|
77
|
+
const existing = entries.get(key);
|
|
78
|
+
if (existing) {
|
|
79
|
+
existing.sources.add(source);
|
|
80
|
+
return copyGrant(existing);
|
|
81
|
+
}
|
|
82
|
+
if (entries.size >= maxEntries) {
|
|
83
|
+
throw new Error(`Studio resource grant limit reached (${maxEntries}).`);
|
|
84
|
+
}
|
|
85
|
+
const entry = {
|
|
86
|
+
kind,
|
|
87
|
+
path: canonicalPath,
|
|
88
|
+
sources: new Set([source]),
|
|
89
|
+
grantedAt: Math.max(0, Number(now()) || 0),
|
|
90
|
+
};
|
|
91
|
+
entries.set(key, entry);
|
|
92
|
+
return copyGrant(entry);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function grantDirectory(pathInput, details = {}) {
|
|
96
|
+
const resolved = canonicalExistingPath(pathInput, fallbackCwd(details), "directory");
|
|
97
|
+
return addGrant("directory", resolved.canonicalPath, normalizeGrantSource(details.source, "explicit-directory"));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function grantFile(pathInput, details = {}) {
|
|
101
|
+
const resolved = canonicalExistingPath(pathInput, fallbackCwd(details), "file");
|
|
102
|
+
return addGrant("file", resolved.canonicalPath, normalizeGrantSource(details.source, "explicit-file"));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function grantDocument(pathInput, details = {}) {
|
|
106
|
+
const cwd = fallbackCwd(details);
|
|
107
|
+
const document = canonicalExistingPath(pathInput, cwd, "file");
|
|
108
|
+
const documentDirectory = dirname(resolveGrantInput(pathInput, cwd));
|
|
109
|
+
const directoryGrant = grantDirectory(documentDirectory, {
|
|
110
|
+
cwd,
|
|
111
|
+
source: normalizeGrantSource(details.source, "document"),
|
|
112
|
+
});
|
|
113
|
+
if (!isPathInsideOrEqualDirectory(document.canonicalPath, directoryGrant.path)) {
|
|
114
|
+
grantFile(document.canonicalPath, { cwd, source: "document-file" });
|
|
115
|
+
}
|
|
116
|
+
return directoryGrant;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function findGrant(pathInput, details = {}) {
|
|
120
|
+
let candidate;
|
|
121
|
+
try {
|
|
122
|
+
candidate = canonicalExistingPath(pathInput, fallbackCwd(details));
|
|
123
|
+
} catch {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
const exactFile = entries.get(`file:${candidate.canonicalPath}`);
|
|
127
|
+
if (exactFile) return copyGrant(exactFile);
|
|
128
|
+
|
|
129
|
+
let bestDirectory = null;
|
|
130
|
+
for (const entry of entries.values()) {
|
|
131
|
+
if (entry.kind !== "directory" || !isPathInsideOrEqualDirectory(candidate.canonicalPath, entry.path)) continue;
|
|
132
|
+
if (!bestDirectory || entry.path.length > bestDirectory.path.length) bestDirectory = entry;
|
|
133
|
+
}
|
|
134
|
+
return bestDirectory ? copyGrant(bestDirectory) : null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return Object.freeze({
|
|
138
|
+
grantDirectory,
|
|
139
|
+
grantFile,
|
|
140
|
+
grantDocument,
|
|
141
|
+
findGrant,
|
|
142
|
+
allows(pathInput, details = {}) {
|
|
143
|
+
return findGrant(pathInput, details) !== null;
|
|
144
|
+
},
|
|
145
|
+
snapshot() {
|
|
146
|
+
return Array.from(entries.values())
|
|
147
|
+
.sort((a, b) => a.path.localeCompare(b.path) || a.kind.localeCompare(b.kind))
|
|
148
|
+
.map(copyGrant);
|
|
149
|
+
},
|
|
150
|
+
clear() {
|
|
151
|
+
entries.clear();
|
|
152
|
+
},
|
|
153
|
+
get size() {
|
|
154
|
+
return entries.size;
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readFileSync, readdirSync, realpathSync, statSync } from "node:fs";
|
|
1
|
+
import { lstatSync, readFileSync, readdirSync, realpathSync, statSync } from "node:fs";
|
|
2
2
|
import { basename, dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
|
|
3
3
|
|
|
4
4
|
export const STUDIO_SIDE_CONTEXT_MAX_FILE_BYTES = 5_000_000;
|
|
@@ -53,8 +53,21 @@ export function resolveStudioSideQuestionRoot(pathInput, fallbackCwd) {
|
|
|
53
53
|
return stats.isDirectory() ? real : dirname(real);
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
export function assertStudioSideQuestionRootStable(rootPath) {
|
|
57
|
+
const requested = resolve(String(rootPath || ""));
|
|
58
|
+
if (lstatSync(requested).isSymbolicLink()) {
|
|
59
|
+
throw new Error("The selected side-question context root changed or now resolves to another folder.");
|
|
60
|
+
}
|
|
61
|
+
const currentReal = realpathSync(requested);
|
|
62
|
+
if (currentReal !== requested) {
|
|
63
|
+
throw new Error("The selected side-question context root changed or now resolves to another folder.");
|
|
64
|
+
}
|
|
65
|
+
if (!statSync(currentReal).isDirectory()) throw new Error("The selected side-question context root is no longer a folder.");
|
|
66
|
+
return currentReal;
|
|
67
|
+
}
|
|
68
|
+
|
|
56
69
|
export function resolveStudioSideQuestionPath(rootPath, pathInput, options = {}) {
|
|
57
|
-
const rootReal =
|
|
70
|
+
const rootReal = assertStudioSideQuestionRootStable(rootPath);
|
|
58
71
|
const raw = String(pathInput || "").trim().replace(/^@/, "");
|
|
59
72
|
if (!raw || /^[a-z][a-z0-9+.-]*:\/\//i.test(raw)) throw new Error("Use a local path inside the selected context root.");
|
|
60
73
|
const candidate = isAbsolute(raw) ? raw : resolve(rootReal, raw);
|
|
@@ -76,7 +89,7 @@ function classifyContextPath(filePath) {
|
|
|
76
89
|
}
|
|
77
90
|
|
|
78
91
|
export function listStudioSideQuestionContext(rootPath, options = {}) {
|
|
79
|
-
const root =
|
|
92
|
+
const root = assertStudioSideQuestionRootStable(rootPath);
|
|
80
93
|
const maxFiles = Math.max(1, Math.min(1_000, Math.floor(Number(options.maxFiles) || 400)));
|
|
81
94
|
const maxDirs = Math.max(1, Math.min(1_000, Math.floor(Number(options.maxDirs) || 500)));
|
|
82
95
|
const maxDepth = Math.max(0, Math.min(12, Math.floor(Number(options.maxDepth) || 8)));
|
|
@@ -177,7 +190,7 @@ export function sliceStudioSideQuestionExtractedText(text, options = {}) {
|
|
|
177
190
|
|
|
178
191
|
export function searchStudioSideQuestionContext(rootPath, queryInput, options = {}) {
|
|
179
192
|
if (options.signal?.aborted) throw new Error("Local context search was cancelled.");
|
|
180
|
-
const root =
|
|
193
|
+
const root = assertStudioSideQuestionRootStable(rootPath);
|
|
181
194
|
const query = String(queryInput || "").trim();
|
|
182
195
|
if (!query) throw new Error("Search query is empty.");
|
|
183
196
|
if (query.length > 500) throw new Error("Search query is too long.");
|
|
@@ -20,6 +20,9 @@ export function normalizeStudioWorkspaceRecoveryState(value) {
|
|
|
20
20
|
if (!value || typeof value !== "object" || value.version !== 1 || typeof value.text !== "string") return null;
|
|
21
21
|
if (value.text.length > STUDIO_WORKSPACE_STATE_MAX_TEXT_CHARS) return null;
|
|
22
22
|
const sourceState = value.sourceState && typeof value.sourceState === "object" ? value.sourceState : {};
|
|
23
|
+
const diskRevision = typeof value.diskRevision === "string" && /^sha256:[a-f0-9]{64}$/i.test(value.diskRevision.trim())
|
|
24
|
+
? value.diskRevision.trim().toLowerCase()
|
|
25
|
+
: null;
|
|
23
26
|
return {
|
|
24
27
|
version: 1,
|
|
25
28
|
savedAt: Math.max(0, finiteNumber(value.savedAt)),
|
|
@@ -29,6 +32,7 @@ export function normalizeStudioWorkspaceRecoveryState(value) {
|
|
|
29
32
|
path: boundedString(sourceState.path, 16_384) || null,
|
|
30
33
|
draftId: boundedString(sourceState.draftId, 256) || null,
|
|
31
34
|
},
|
|
35
|
+
diskRevision,
|
|
32
36
|
resourceDir: boundedString(value.resourceDir, 16_384),
|
|
33
37
|
editorView: boundedString(value.editorView, 100),
|
|
34
38
|
rightView: boundedString(value.rightView, 100),
|