collabmd 0.1.20 → 0.1.21
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 +4 -2
- package/package.json +1 -1
- package/public/assets/css/style.css +1 -1
- package/public/assets/js/chunks/chunk-GFDM7YCF.js +1 -0
- package/public/assets/js/chunks/editor-session-EAUBJCHH.js +22 -0
- package/public/assets/js/chunks/{preview-render-compiler-UZ4SZDQQ.js → preview-render-compiler-6Y7TKXFJ.js} +1 -1
- package/public/assets/js/chunks/{quick-switcher-controller-J7I3CUET.js → quick-switcher-controller-A6SKH5HI.js} +1 -1
- package/public/assets/js/{excalidraw-editor-ZDYKMZOL.js → excalidraw-editor-TCIH6GJL.js} +1 -1
- package/public/assets/js/excalidraw-editor.js +1 -1
- package/public/assets/js/main.js +68 -68
- package/public/assets/js/preview-render-worker.js +15 -15
- package/src/client/application/app-shell/git-feature.js +57 -18
- package/src/client/application/app-shell/ui-feature.js +4 -0
- package/src/client/bootstrap/collabmd-app-shell.js +12 -0
- package/src/client/domain/vault-utils.js +2 -2
- package/src/client/excalidraw-editor.js +2 -1
- package/src/client/infrastructure/editor-session.js +4 -0
- package/src/client/infrastructure/editor-view-adapter.js +181 -1
- package/src/client/infrastructure/workspace-sync-client.js +324 -0
- package/src/client/presentation/comment-ui-controller.js +192 -5
- package/src/client/presentation/file-explorer-controller.js +15 -3
- package/src/client/presentation/file-explorer-view.js +152 -11
- package/src/client/styles/style.css +46 -2
- package/src/domain/wiki-link-resolver.js +16 -5
- package/src/domain/workspace-change.js +68 -0
- package/src/domain/workspace-room.js +3 -0
- package/src/server/create-app-server.js +41 -10
- package/src/server/domain/backlink-index.js +94 -1
- package/src/server/domain/collaboration/collaboration-room.js +191 -22
- package/src/server/domain/collaboration/room-registry.js +64 -10
- package/src/server/infrastructure/git/responses.js +12 -19
- package/src/server/infrastructure/http/create-git-api-command-handler.js +53 -43
- package/src/server/infrastructure/http/create-git-api-handler.js +2 -0
- package/src/server/infrastructure/http/create-request-handler.js +7 -0
- package/src/server/infrastructure/http/create-vault-api-command-handler.js +58 -14
- package/src/server/infrastructure/http/create-vault-api-handler.js +3 -0
- package/src/server/infrastructure/http/create-vault-api-query-handler.js +3 -1
- package/src/server/infrastructure/http/http-response.js +65 -28
- package/src/server/infrastructure/persistence/vault-file-store.js +163 -52
- package/src/server/infrastructure/workspace/file-system-sync-service.js +488 -0
- package/src/server/infrastructure/workspace/workspace-mutation-coordinator.js +551 -0
- package/public/assets/js/chunks/chunk-R3DDMJHH.js +0 -1
- package/public/assets/js/chunks/editor-session-TAV2VXTA.js +0 -22
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
isMermaidFilePath,
|
|
4
4
|
isPlantUmlFilePath,
|
|
5
5
|
} from '../../../domain/file-kind.js';
|
|
6
|
+
import { createWorkspaceChange } from '../../../domain/workspace-change.js';
|
|
6
7
|
import { createRequestError, getRequestErrorStatusCode } from './http-errors.js';
|
|
7
8
|
import { jsonResponse } from './http-response.js';
|
|
8
9
|
import { parseJsonBody, readBinaryRequestBody } from './request-body.js';
|
|
@@ -36,6 +37,11 @@ function selectWriteOperation(vaultFileStore, filePath, content) {
|
|
|
36
37
|
return vaultFileStore.writeMarkdownFile(filePath, content);
|
|
37
38
|
}
|
|
38
39
|
|
|
40
|
+
function readRequestId(req) {
|
|
41
|
+
const value = String(req.headers['x-collabmd-request-id'] || '').trim();
|
|
42
|
+
return value ? value.slice(0, 120) : null;
|
|
43
|
+
}
|
|
44
|
+
|
|
39
45
|
function handleVaultError(req, res, error, logMessage, fallbackMessage) {
|
|
40
46
|
const statusCode = getRequestErrorStatusCode(error);
|
|
41
47
|
if (statusCode) {
|
|
@@ -49,9 +55,8 @@ function handleVaultError(req, res, error, logMessage, fallbackMessage) {
|
|
|
49
55
|
}
|
|
50
56
|
|
|
51
57
|
export function createVaultApiCommandHandler({
|
|
52
|
-
backlinkIndex,
|
|
53
|
-
roomRegistry = null,
|
|
54
58
|
vaultFileStore,
|
|
59
|
+
workspaceMutationCoordinator = null,
|
|
55
60
|
}) {
|
|
56
61
|
return async function handleVaultApiCommand(req, res, requestUrl) {
|
|
57
62
|
if (requestUrl.pathname === '/api/file' && req.method === 'PUT') {
|
|
@@ -68,6 +73,15 @@ export function createVaultApiCommandHandler({
|
|
|
68
73
|
return true;
|
|
69
74
|
}
|
|
70
75
|
|
|
76
|
+
await workspaceMutationCoordinator?.apply?.({
|
|
77
|
+
action: 'write-file',
|
|
78
|
+
origin: 'api',
|
|
79
|
+
requestId: readRequestId(req),
|
|
80
|
+
workspaceChange: createWorkspaceChange({
|
|
81
|
+
changedPaths: [body.path],
|
|
82
|
+
}),
|
|
83
|
+
});
|
|
84
|
+
|
|
71
85
|
jsonResponse(req, res, 200, { ok: true });
|
|
72
86
|
} catch (error) {
|
|
73
87
|
handleVaultError(req, res, error, '[api] Failed to write file:', 'Failed to write file');
|
|
@@ -97,6 +111,15 @@ export function createVaultApiCommandHandler({
|
|
|
97
111
|
return true;
|
|
98
112
|
}
|
|
99
113
|
|
|
114
|
+
await workspaceMutationCoordinator?.apply?.({
|
|
115
|
+
action: 'upload-attachment',
|
|
116
|
+
origin: 'api',
|
|
117
|
+
requestId: readRequestId(req),
|
|
118
|
+
workspaceChange: createWorkspaceChange({
|
|
119
|
+
changedPaths: [result.path],
|
|
120
|
+
}),
|
|
121
|
+
});
|
|
122
|
+
|
|
100
123
|
jsonResponse(req, res, 201, {
|
|
101
124
|
markdown: result.markdownSnippet,
|
|
102
125
|
ok: true,
|
|
@@ -121,8 +144,14 @@ export function createVaultApiCommandHandler({
|
|
|
121
144
|
jsonResponse(req, res, 409, { error: result.error });
|
|
122
145
|
return true;
|
|
123
146
|
}
|
|
124
|
-
|
|
125
|
-
|
|
147
|
+
await workspaceMutationCoordinator?.apply?.({
|
|
148
|
+
action: 'create-file',
|
|
149
|
+
origin: 'api',
|
|
150
|
+
requestId: readRequestId(req),
|
|
151
|
+
workspaceChange: createWorkspaceChange({
|
|
152
|
+
changedPaths: [body.path],
|
|
153
|
+
}),
|
|
154
|
+
});
|
|
126
155
|
jsonResponse(req, res, 201, { ok: true, path: body.path });
|
|
127
156
|
} catch (error) {
|
|
128
157
|
handleVaultError(req, res, error, '[api] Failed to create file:', 'Failed to create file');
|
|
@@ -137,21 +166,22 @@ export function createVaultApiCommandHandler({
|
|
|
137
166
|
return true;
|
|
138
167
|
}
|
|
139
168
|
|
|
140
|
-
const activeRoom = roomRegistry?.get(filePath);
|
|
141
169
|
try {
|
|
142
|
-
activeRoom?.markDeleted?.();
|
|
143
170
|
const result = await vaultFileStore.deleteFile(filePath);
|
|
144
171
|
if (!result.ok) {
|
|
145
|
-
activeRoom?.unmarkDeleted?.();
|
|
146
172
|
jsonResponse(req, res, 400, { error: result.error });
|
|
147
173
|
return true;
|
|
148
174
|
}
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
175
|
+
await workspaceMutationCoordinator?.apply?.({
|
|
176
|
+
action: 'delete-file',
|
|
177
|
+
origin: 'api',
|
|
178
|
+
requestId: readRequestId(req),
|
|
179
|
+
workspaceChange: createWorkspaceChange({
|
|
180
|
+
deletedPaths: [filePath],
|
|
181
|
+
}),
|
|
182
|
+
});
|
|
152
183
|
jsonResponse(req, res, 200, { ok: true });
|
|
153
184
|
} catch (error) {
|
|
154
|
-
activeRoom?.unmarkDeleted?.();
|
|
155
185
|
console.error('[api] Failed to delete file:', error.message);
|
|
156
186
|
jsonResponse(req, res, 500, { error: 'Failed to delete file' });
|
|
157
187
|
}
|
|
@@ -171,9 +201,14 @@ export function createVaultApiCommandHandler({
|
|
|
171
201
|
jsonResponse(req, res, 400, { error: result.error });
|
|
172
202
|
return true;
|
|
173
203
|
}
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
204
|
+
await workspaceMutationCoordinator?.apply?.({
|
|
205
|
+
action: 'rename-file',
|
|
206
|
+
origin: 'api',
|
|
207
|
+
requestId: readRequestId(req),
|
|
208
|
+
workspaceChange: createWorkspaceChange({
|
|
209
|
+
renamedPaths: [{ oldPath: body.oldPath, newPath: body.newPath }],
|
|
210
|
+
}),
|
|
211
|
+
});
|
|
177
212
|
jsonResponse(req, res, 200, { ok: true, path: body.newPath });
|
|
178
213
|
} catch (error) {
|
|
179
214
|
handleVaultError(req, res, error, '[api] Failed to rename file:', 'Failed to rename file');
|
|
@@ -195,6 +230,15 @@ export function createVaultApiCommandHandler({
|
|
|
195
230
|
return true;
|
|
196
231
|
}
|
|
197
232
|
|
|
233
|
+
await workspaceMutationCoordinator?.apply?.({
|
|
234
|
+
action: 'create-directory',
|
|
235
|
+
origin: 'api',
|
|
236
|
+
requestId: readRequestId(req),
|
|
237
|
+
workspaceChange: createWorkspaceChange({
|
|
238
|
+
changedPaths: [body.path],
|
|
239
|
+
}),
|
|
240
|
+
});
|
|
241
|
+
|
|
198
242
|
jsonResponse(req, res, 201, { ok: true });
|
|
199
243
|
} catch (error) {
|
|
200
244
|
handleVaultError(req, res, error, '[api] Failed to create directory:', 'Failed to create directory');
|
|
@@ -7,15 +7,18 @@ export function createVaultApiHandler({
|
|
|
7
7
|
plantUmlRenderer = null,
|
|
8
8
|
roomRegistry = null,
|
|
9
9
|
vaultFileStore,
|
|
10
|
+
workspaceMutationCoordinator = null,
|
|
10
11
|
}) {
|
|
11
12
|
const handleVaultApiQuery = createVaultApiQueryHandler({
|
|
12
13
|
backlinkIndex,
|
|
13
14
|
vaultFileStore,
|
|
15
|
+
workspaceMutationCoordinator,
|
|
14
16
|
});
|
|
15
17
|
const handleVaultApiCommand = createVaultApiCommandHandler({
|
|
16
18
|
backlinkIndex,
|
|
17
19
|
roomRegistry,
|
|
18
20
|
vaultFileStore,
|
|
21
|
+
workspaceMutationCoordinator,
|
|
19
22
|
});
|
|
20
23
|
const handlePlantUmlApi = createPlantUmlApiHandler({
|
|
21
24
|
plantUmlRenderer,
|
|
@@ -59,11 +59,13 @@ function selectReadOperation(vaultFileStore, filePath) {
|
|
|
59
59
|
export function createVaultApiQueryHandler({
|
|
60
60
|
backlinkIndex,
|
|
61
61
|
vaultFileStore,
|
|
62
|
+
workspaceMutationCoordinator = null,
|
|
62
63
|
}) {
|
|
63
64
|
return async function handleVaultApiQuery(req, res, requestUrl) {
|
|
64
65
|
if (requestUrl.pathname === '/api/files' && req.method === 'GET') {
|
|
65
66
|
try {
|
|
66
|
-
|
|
67
|
+
const tree = workspaceMutationCoordinator?.getWorkspaceTree?.() ?? await vaultFileStore.tree();
|
|
68
|
+
jsonResponse(req, res, 200, { tree });
|
|
67
69
|
} catch (error) {
|
|
68
70
|
console.error('[api] Failed to read file tree:', error.message);
|
|
69
71
|
jsonResponse(req, res, 500, { error: 'Failed to read file tree' });
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
brotliCompress,
|
|
3
|
+
constants as zlibConstants,
|
|
4
|
+
gzip,
|
|
5
|
+
} from 'node:zlib';
|
|
2
6
|
|
|
3
7
|
const COMPRESSIBLE_CONTENT_TYPE_PATTERN = /^(?:text\/|application\/(?:javascript|json|xml)|image\/svg\+xml)/i;
|
|
4
8
|
const MIN_COMPRESSIBLE_BYTES = 1024;
|
|
@@ -41,9 +45,9 @@ function resolveCompressionEncoding(acceptEncodingHeader) {
|
|
|
41
45
|
return null;
|
|
42
46
|
}
|
|
43
47
|
|
|
44
|
-
function
|
|
48
|
+
function prepareBody(req, body, contentType) {
|
|
45
49
|
if (body === undefined || body === null) {
|
|
46
|
-
return { body: null,
|
|
50
|
+
return { body: null, encoding: null };
|
|
47
51
|
}
|
|
48
52
|
|
|
49
53
|
const bodyBuffer = Buffer.isBuffer(body)
|
|
@@ -54,27 +58,48 @@ function maybeCompressBody(req, body, contentType) {
|
|
|
54
58
|
bodyBuffer.byteLength < MIN_COMPRESSIBLE_BYTES
|
|
55
59
|
|| !COMPRESSIBLE_CONTENT_TYPE_PATTERN.test(String(contentType || ''))
|
|
56
60
|
) {
|
|
57
|
-
return { body: bodyBuffer,
|
|
61
|
+
return { body: bodyBuffer, encoding: null };
|
|
58
62
|
}
|
|
59
63
|
|
|
60
64
|
const encoding = resolveCompressionEncoding(req.headers['accept-encoding']);
|
|
61
65
|
if (!encoding) {
|
|
62
|
-
return { body: bodyBuffer,
|
|
66
|
+
return { body: bodyBuffer, encoding: null };
|
|
63
67
|
}
|
|
64
68
|
|
|
65
|
-
|
|
66
|
-
|
|
69
|
+
return { body: bodyBuffer, encoding };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function compressBody(bodyBuffer, encoding, callback) {
|
|
73
|
+
if (encoding === 'br') {
|
|
74
|
+
brotliCompress(bodyBuffer, {
|
|
67
75
|
params: {
|
|
68
76
|
[zlibConstants.BROTLI_PARAM_QUALITY]: 5,
|
|
69
77
|
},
|
|
70
|
-
})
|
|
71
|
-
|
|
78
|
+
}, callback);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
gzip(bodyBuffer, { level: 6 }, callback);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function writeResponseHead(res, statusCode, headers, bodyBuffer) {
|
|
86
|
+
const responseHeaders = { ...headers };
|
|
87
|
+
if (bodyBuffer) {
|
|
88
|
+
responseHeaders['Content-Length'] = String(bodyBuffer.byteLength);
|
|
89
|
+
}
|
|
72
90
|
|
|
73
|
-
|
|
74
|
-
|
|
91
|
+
res.writeHead(statusCode, responseHeaders);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function writePreparedBody(req, res, statusCode, headers, bodyBuffer) {
|
|
95
|
+
writeResponseHead(res, statusCode, headers, bodyBuffer);
|
|
96
|
+
|
|
97
|
+
if (req.method === 'HEAD' || statusCode === 204 || statusCode === 304) {
|
|
98
|
+
res.end();
|
|
99
|
+
return;
|
|
75
100
|
}
|
|
76
101
|
|
|
77
|
-
|
|
102
|
+
res.end(bodyBuffer ?? undefined);
|
|
78
103
|
}
|
|
79
104
|
|
|
80
105
|
export function setHeaders(res, headers) {
|
|
@@ -116,30 +141,42 @@ export function sendResponse(req, res, {
|
|
|
116
141
|
statusCode = 200,
|
|
117
142
|
} = {}) {
|
|
118
143
|
const contentType = headers['Content-Type'] || headers['content-type'] || '';
|
|
119
|
-
const prepared =
|
|
144
|
+
const prepared = prepareBody(req, body, contentType);
|
|
120
145
|
|
|
121
146
|
if (body !== null) {
|
|
122
147
|
appendVaryHeader(res, 'Accept-Encoding');
|
|
123
148
|
}
|
|
124
149
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
if (prepared.compressed && prepared.encoding) {
|
|
128
|
-
responseHeaders['Content-Encoding'] = prepared.encoding;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
if (prepared.body) {
|
|
132
|
-
responseHeaders['Content-Length'] = String(prepared.body.byteLength);
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
res.writeHead(statusCode, responseHeaders);
|
|
136
|
-
|
|
137
|
-
if (req.method === 'HEAD' || statusCode === 204 || statusCode === 304) {
|
|
138
|
-
res.end();
|
|
150
|
+
if (!prepared.body || !prepared.encoding || req.method === 'HEAD' || statusCode === 204 || statusCode === 304) {
|
|
151
|
+
writePreparedBody(req, res, statusCode, headers, prepared.body);
|
|
139
152
|
return;
|
|
140
153
|
}
|
|
141
154
|
|
|
142
|
-
|
|
155
|
+
compressBody(prepared.body, prepared.encoding, (error, compressedBody) => {
|
|
156
|
+
if (res.writableEnded || res.destroyed) {
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (error) {
|
|
161
|
+
console.error('[http] Failed to compress response body:', error.message);
|
|
162
|
+
if (!res.headersSent) {
|
|
163
|
+
writePreparedBody(req, res, statusCode, headers, prepared.body);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
res.destroy(error);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (!compressedBody || compressedBody.byteLength >= prepared.body.byteLength) {
|
|
171
|
+
writePreparedBody(req, res, statusCode, headers, prepared.body);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
writePreparedBody(req, res, statusCode, {
|
|
176
|
+
...headers,
|
|
177
|
+
'Content-Encoding': prepared.encoding,
|
|
178
|
+
}, compressedBody);
|
|
179
|
+
});
|
|
143
180
|
}
|
|
144
181
|
|
|
145
182
|
export function jsonResponse(req, res, statusCode, data) {
|
|
@@ -60,6 +60,19 @@ async function pathExists(pathValue) {
|
|
|
60
60
|
}
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
function createWorkspaceEntry(relativePath, type) {
|
|
64
|
+
return {
|
|
65
|
+
fileKind: type === 'directory' ? null : getVaultTreeNodeType(relativePath),
|
|
66
|
+
name: basename(relativePath),
|
|
67
|
+
nodeType: type,
|
|
68
|
+
parentPath: dirname(relativePath).replace(/\\/g, '/') === '.'
|
|
69
|
+
? ''
|
|
70
|
+
: dirname(relativePath).replace(/\\/g, '/'),
|
|
71
|
+
path: relativePath,
|
|
72
|
+
type: type === 'directory' ? 'directory' : getVaultTreeNodeType(relativePath),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
63
76
|
async function cleanupPaths(paths = []) {
|
|
64
77
|
await Promise.allSettled(paths.filter(Boolean).map((pathValue) => rm(pathValue, { force: true })));
|
|
65
78
|
}
|
|
@@ -150,6 +163,19 @@ export class VaultFileStore {
|
|
|
150
163
|
constructor({ vaultDir }) {
|
|
151
164
|
this.vaultDir = resolve(vaultDir);
|
|
152
165
|
this.sidecarStore = new SidecarStore({ vaultDir: this.vaultDir });
|
|
166
|
+
this.managedWriteTracker = null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
setManagedWriteTracker(tracker) {
|
|
170
|
+
this.managedWriteTracker = tracker ?? null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async runManagedWrite(paths, operation) {
|
|
174
|
+
if (!this.managedWriteTracker?.runManagedWrite) {
|
|
175
|
+
return operation();
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return this.managedWriteTracker.runManagedWrite(paths, operation);
|
|
153
179
|
}
|
|
154
180
|
|
|
155
181
|
async tree() {
|
|
@@ -251,11 +277,13 @@ export class VaultFileStore {
|
|
|
251
277
|
}
|
|
252
278
|
|
|
253
279
|
try {
|
|
254
|
-
await
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
280
|
+
await this.runManagedWrite([filePath], async () => {
|
|
281
|
+
await mkdir(dirname(resolved.absolute), { recursive: true });
|
|
282
|
+
await writeFile(resolved.absolute, content, 'utf-8');
|
|
283
|
+
if (invalidateCollaborationSnapshot) {
|
|
284
|
+
await this.deleteCollaborationSnapshot(filePath);
|
|
285
|
+
}
|
|
286
|
+
});
|
|
259
287
|
return { ok: true };
|
|
260
288
|
} catch (error) {
|
|
261
289
|
return { ok: false, error: error.message };
|
|
@@ -356,8 +384,10 @@ export class VaultFileStore {
|
|
|
356
384
|
}
|
|
357
385
|
|
|
358
386
|
try {
|
|
359
|
-
await
|
|
360
|
-
|
|
387
|
+
await this.runManagedWrite([storedPath], async () => {
|
|
388
|
+
await mkdir(dirname(absolutePath), { recursive: true });
|
|
389
|
+
await writeFile(absolutePath, content);
|
|
390
|
+
});
|
|
361
391
|
} catch (error) {
|
|
362
392
|
return { ok: false, error: error.message };
|
|
363
393
|
}
|
|
@@ -412,49 +442,51 @@ export class VaultFileStore {
|
|
|
412
442
|
const committedOperations = [];
|
|
413
443
|
|
|
414
444
|
try {
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
445
|
+
await this.runManagedWrite([filePath], async () => {
|
|
446
|
+
for (const operation of operations) {
|
|
447
|
+
if (operation.kind !== 'write') {
|
|
448
|
+
continue;
|
|
449
|
+
}
|
|
419
450
|
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
451
|
+
const tempPath = createTransactionalPath(operation.targetPath, 'tmp');
|
|
452
|
+
await mkdir(dirname(operation.targetPath), { recursive: true });
|
|
453
|
+
await writeFile(tempPath, operation.value, operation.writeOptions);
|
|
454
|
+
operation.tempPath = tempPath;
|
|
455
|
+
stagedWrites.push(tempPath);
|
|
456
|
+
}
|
|
426
457
|
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
458
|
+
for (const operation of operations) {
|
|
459
|
+
const hadExistingTarget = await pathExists(operation.targetPath);
|
|
460
|
+
const backupPath = hadExistingTarget
|
|
461
|
+
? createTransactionalPath(operation.targetPath, 'bak')
|
|
462
|
+
: null;
|
|
432
463
|
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
464
|
+
if (backupPath) {
|
|
465
|
+
await rename(operation.targetPath, backupPath);
|
|
466
|
+
}
|
|
436
467
|
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
468
|
+
try {
|
|
469
|
+
if (operation.kind === 'write') {
|
|
470
|
+
await rename(operation.tempPath, operation.targetPath);
|
|
471
|
+
const stagedIndex = stagedWrites.indexOf(operation.tempPath);
|
|
472
|
+
if (stagedIndex >= 0) {
|
|
473
|
+
stagedWrites.splice(stagedIndex, 1);
|
|
474
|
+
}
|
|
443
475
|
}
|
|
476
|
+
} catch (error) {
|
|
477
|
+
if (backupPath) {
|
|
478
|
+
await rename(backupPath, operation.targetPath);
|
|
479
|
+
}
|
|
480
|
+
throw error;
|
|
444
481
|
}
|
|
445
|
-
} catch (error) {
|
|
446
|
-
if (backupPath) {
|
|
447
|
-
await rename(backupPath, operation.targetPath);
|
|
448
|
-
}
|
|
449
|
-
throw error;
|
|
450
|
-
}
|
|
451
482
|
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
483
|
+
committedOperations.push({
|
|
484
|
+
backupPath,
|
|
485
|
+
kind: operation.kind,
|
|
486
|
+
targetPath: operation.targetPath,
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
});
|
|
458
490
|
|
|
459
491
|
await cleanupPaths(committedOperations.map((operation) => operation.backupPath));
|
|
460
492
|
return { ok: true };
|
|
@@ -510,9 +542,11 @@ export class VaultFileStore {
|
|
|
510
542
|
}
|
|
511
543
|
}
|
|
512
544
|
|
|
513
|
-
await
|
|
514
|
-
|
|
515
|
-
|
|
545
|
+
await this.runManagedWrite([filePath], async () => {
|
|
546
|
+
await mkdir(dirname(absolute), { recursive: true });
|
|
547
|
+
await writeFile(absolute, content, 'utf-8');
|
|
548
|
+
await this.deleteCollaborationSnapshot(filePath);
|
|
549
|
+
});
|
|
516
550
|
return { ok: true };
|
|
517
551
|
}
|
|
518
552
|
|
|
@@ -523,8 +557,10 @@ export class VaultFileStore {
|
|
|
523
557
|
}
|
|
524
558
|
|
|
525
559
|
try {
|
|
526
|
-
await
|
|
527
|
-
|
|
560
|
+
await this.runManagedWrite([filePath], async () => {
|
|
561
|
+
await rm(absolute, { force: true });
|
|
562
|
+
await this.sidecarStore.deleteAllForFile(filePath);
|
|
563
|
+
});
|
|
528
564
|
return { ok: true };
|
|
529
565
|
} catch (error) {
|
|
530
566
|
return { ok: false, error: error.message };
|
|
@@ -538,9 +574,11 @@ export class VaultFileStore {
|
|
|
538
574
|
}
|
|
539
575
|
|
|
540
576
|
try {
|
|
541
|
-
await
|
|
542
|
-
|
|
543
|
-
|
|
577
|
+
await this.runManagedWrite([oldPath, newPath], async () => {
|
|
578
|
+
await mkdir(dirname(absoluteNew), { recursive: true });
|
|
579
|
+
await rename(absoluteOld, absoluteNew);
|
|
580
|
+
await this.sidecarStore.renameAllForFile(oldPath, newPath);
|
|
581
|
+
});
|
|
544
582
|
return { ok: true };
|
|
545
583
|
} catch (error) {
|
|
546
584
|
return { ok: false, error: error.message };
|
|
@@ -554,7 +592,7 @@ export class VaultFileStore {
|
|
|
554
592
|
}
|
|
555
593
|
|
|
556
594
|
try {
|
|
557
|
-
await mkdir(absolute, { recursive: true });
|
|
595
|
+
await this.runManagedWrite([dirPath], () => mkdir(absolute, { recursive: true }));
|
|
558
596
|
return { ok: true };
|
|
559
597
|
} catch (error) {
|
|
560
598
|
return { ok: false, error: error.message };
|
|
@@ -633,4 +671,77 @@ export class VaultFileStore {
|
|
|
633
671
|
|
|
634
672
|
return toVaultRelativePath(this.vaultDir, absolute);
|
|
635
673
|
}
|
|
674
|
+
|
|
675
|
+
async scanWorkspaceState() {
|
|
676
|
+
const entries = new Map();
|
|
677
|
+
const metadata = new Map();
|
|
678
|
+
|
|
679
|
+
const visitDirectory = async (dirPath) => {
|
|
680
|
+
let dirEntries;
|
|
681
|
+
try {
|
|
682
|
+
dirEntries = await readdir(dirPath, { withFileTypes: true });
|
|
683
|
+
} catch {
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
const sorted = dirEntries.sort((left, right) => {
|
|
688
|
+
if (left.isDirectory() && !right.isDirectory()) return -1;
|
|
689
|
+
if (!left.isDirectory() && right.isDirectory()) return 1;
|
|
690
|
+
return left.name.localeCompare(right.name, undefined, { sensitivity: 'base' });
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
for (const entry of sorted) {
|
|
694
|
+
if (isIgnoredVaultEntry(entry.name)) {
|
|
695
|
+
continue;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
const fullPath = join(dirPath, entry.name);
|
|
699
|
+
const relativePath = toVaultRelativePath(this.vaultDir, fullPath).replace(/\\/g, '/');
|
|
700
|
+
|
|
701
|
+
if (entry.isDirectory()) {
|
|
702
|
+
entries.set(relativePath, createWorkspaceEntry(relativePath, 'directory'));
|
|
703
|
+
try {
|
|
704
|
+
const info = await stat(fullPath);
|
|
705
|
+
metadata.set(relativePath, {
|
|
706
|
+
inode: Number(info.ino || 0),
|
|
707
|
+
mtimeMs: Number(info.mtimeMs || 0),
|
|
708
|
+
path: relativePath,
|
|
709
|
+
size: 0,
|
|
710
|
+
type: 'directory',
|
|
711
|
+
});
|
|
712
|
+
} catch {
|
|
713
|
+
// Ignore transient directories that disappear during scans.
|
|
714
|
+
}
|
|
715
|
+
await visitDirectory(fullPath);
|
|
716
|
+
continue;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
if (!isVaultFilePath(entry.name)) {
|
|
720
|
+
continue;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
entries.set(relativePath, createWorkspaceEntry(relativePath, 'file'));
|
|
724
|
+
try {
|
|
725
|
+
const info = await stat(fullPath);
|
|
726
|
+
metadata.set(relativePath, {
|
|
727
|
+
inode: Number(info.ino || 0),
|
|
728
|
+
mtimeMs: Number(info.mtimeMs || 0),
|
|
729
|
+
path: relativePath,
|
|
730
|
+
size: Number(info.size || 0),
|
|
731
|
+
type: 'file',
|
|
732
|
+
});
|
|
733
|
+
} catch {
|
|
734
|
+
// Ignore transient files that disappear during scans.
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
};
|
|
738
|
+
|
|
739
|
+
await visitDirectory(this.vaultDir);
|
|
740
|
+
|
|
741
|
+
return {
|
|
742
|
+
entries,
|
|
743
|
+
metadata,
|
|
744
|
+
scannedAt: Date.now(),
|
|
745
|
+
};
|
|
746
|
+
}
|
|
636
747
|
}
|