collabmd 0.1.19 → 0.1.20
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 +10 -10
- package/docker-compose.yml +1 -1
- package/package.json +1 -1
- package/public/assets/css/style.css +1 -1
- package/public/assets/js/chunks/{editor-session-AH6Z3MXW.js → editor-session-TAV2VXTA.js} +11 -11
- package/public/assets/js/main.js +95 -68
- package/public/index.html +14 -4
- package/src/client/application/app-shell/git-feature.js +29 -1
- package/src/client/application/app-shell-elements.js +1 -0
- package/src/client/bootstrap/collabmd-app-shell.js +4 -0
- package/src/client/infrastructure/editor-view-adapter.js +64 -6
- package/src/client/presentation/backlinks-panel.js +157 -101
- package/src/client/presentation/comment-ui-controller.js +41 -5
- package/src/client/presentation/git-panel-controller.js +73 -0
- package/src/client/presentation/outline-controller.js +25 -0
- package/src/client/styles/style.css +138 -7
- package/src/server/infrastructure/git/errors.js +4 -1
- package/src/server/infrastructure/git/git-service.js +215 -1
- package/src/server/infrastructure/http/create-git-api-command-handler.js +6 -1
- package/src/server/infrastructure/http/create-git-api-query-handler.js +16 -1
- package/src/server/infrastructure/persistence/pull-backup-store.js +283 -0
- package/src/server/infrastructure/persistence/vault-file-store.js +16 -0
|
@@ -16,7 +16,11 @@ async function parseRequiredBody(req, res, fieldName) {
|
|
|
16
16
|
function handleGitError(req, res, error, logMessage, fallbackMessage) {
|
|
17
17
|
const statusCode = getRequestErrorStatusCode(error);
|
|
18
18
|
if (statusCode) {
|
|
19
|
-
|
|
19
|
+
const payload = { error: error.message };
|
|
20
|
+
if (typeof error?.requestCode === 'string') {
|
|
21
|
+
payload.code = error.requestCode;
|
|
22
|
+
}
|
|
23
|
+
jsonResponse(req, res, statusCode, payload);
|
|
20
24
|
return true;
|
|
21
25
|
}
|
|
22
26
|
|
|
@@ -47,6 +51,7 @@ async function applyWorkspaceMutationEffects({
|
|
|
47
51
|
}
|
|
48
52
|
|
|
49
53
|
await vaultFileStore?.reconcileSidecars?.(workspaceChange);
|
|
54
|
+
await vaultFileStore?.reconcileCollaborationSnapshots?.(workspaceChange);
|
|
50
55
|
await backlinkIndex?.build?.();
|
|
51
56
|
await roomRegistry?.reconcileWorkspaceChange?.(workspaceChange);
|
|
52
57
|
return responsePayload;
|
|
@@ -21,7 +21,11 @@ function isTruthyParam(value) {
|
|
|
21
21
|
function handleGitError(req, res, error, message, fallback) {
|
|
22
22
|
const statusCode = getRequestErrorStatusCode(error);
|
|
23
23
|
if (statusCode) {
|
|
24
|
-
|
|
24
|
+
const payload = { error: error.message };
|
|
25
|
+
if (typeof error?.requestCode === 'string') {
|
|
26
|
+
payload.code = error.requestCode;
|
|
27
|
+
}
|
|
28
|
+
jsonResponse(req, res, statusCode, payload);
|
|
25
29
|
return true;
|
|
26
30
|
}
|
|
27
31
|
|
|
@@ -57,6 +61,17 @@ export function createGitApiQueryHandler({ gitService }) {
|
|
|
57
61
|
return true;
|
|
58
62
|
}
|
|
59
63
|
|
|
64
|
+
if (requestUrl.pathname === '/api/git/pull-backups' && req.method === 'GET') {
|
|
65
|
+
try {
|
|
66
|
+
jsonResponse(req, res, 200, {
|
|
67
|
+
backups: await gitService.listPullBackups(),
|
|
68
|
+
});
|
|
69
|
+
} catch (error) {
|
|
70
|
+
handleGitError(req, res, error, '[api] Failed to read pull backups:', 'Failed to read pull backups');
|
|
71
|
+
}
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
|
|
60
75
|
return false;
|
|
61
76
|
};
|
|
62
77
|
}
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import { copyFile, mkdir, readFile, readdir, stat, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname, join, resolve } from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { sanitizeVaultPath } from './path-utils.js';
|
|
5
|
+
|
|
6
|
+
const PULL_BACKUP_STORAGE_ROOT = '.collabmd/pull-backups';
|
|
7
|
+
const BACKUP_METADATA_PREFIX = '<!-- collabmd-pull-backup ';
|
|
8
|
+
const BACKUP_METADATA_SUFFIX = ' -->';
|
|
9
|
+
|
|
10
|
+
function normalizeRelativePath(pathValue = '') {
|
|
11
|
+
return String(pathValue).replace(/\\/g, '/');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function createBackupId(headRef = null, createdAt = new Date().toISOString()) {
|
|
15
|
+
const timestamp = normalizeRelativePath(createdAt)
|
|
16
|
+
.replace(/[^0-9T]/g, '')
|
|
17
|
+
.replace('T', '-')
|
|
18
|
+
.replace(/Z$/u, '');
|
|
19
|
+
const shortHead = String(headRef ?? 'workspace').trim().slice(0, 7) || 'workspace';
|
|
20
|
+
return `${timestamp}-${shortHead}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function formatBackupMetadata(metadata = {}) {
|
|
24
|
+
return `${BACKUP_METADATA_PREFIX}${JSON.stringify(metadata)}${BACKUP_METADATA_SUFFIX}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function parseBackupMetadata(content = '') {
|
|
28
|
+
const firstLine = String(content).split(/\r?\n/u)[0] ?? '';
|
|
29
|
+
if (!firstLine.startsWith(BACKUP_METADATA_PREFIX) || !firstLine.endsWith(BACKUP_METADATA_SUFFIX)) {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
return JSON.parse(firstLine.slice(BACKUP_METADATA_PREFIX.length, -BACKUP_METADATA_SUFFIX.length));
|
|
35
|
+
} catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function formatSummary({
|
|
41
|
+
backupEntries = [],
|
|
42
|
+
backupId,
|
|
43
|
+
branch = null,
|
|
44
|
+
createdAt,
|
|
45
|
+
fileCount = 0,
|
|
46
|
+
headRef = null,
|
|
47
|
+
targetRef = null,
|
|
48
|
+
} = {}) {
|
|
49
|
+
const metadata = formatBackupMetadata({
|
|
50
|
+
backupId,
|
|
51
|
+
branch,
|
|
52
|
+
createdAt,
|
|
53
|
+
fileCount,
|
|
54
|
+
headRef,
|
|
55
|
+
targetRef,
|
|
56
|
+
});
|
|
57
|
+
const entryLines = backupEntries.length === 0
|
|
58
|
+
? ['No overlapping files could be copied from the worktree.']
|
|
59
|
+
: backupEntries.flatMap((entry) => {
|
|
60
|
+
const lines = [`- Original path: \`${entry.path}\``];
|
|
61
|
+
if (entry.backupPath) {
|
|
62
|
+
lines.push(` Saved copy: \`${entry.backupPath}\``);
|
|
63
|
+
} else {
|
|
64
|
+
lines.push(' Saved copy: none (the file no longer existed in the worktree at backup time)');
|
|
65
|
+
}
|
|
66
|
+
if (entry.stagedPatchPath) {
|
|
67
|
+
lines.push(` Staged patch: \`${entry.stagedPatchPath}\``);
|
|
68
|
+
}
|
|
69
|
+
if (entry.worktreePatchPath) {
|
|
70
|
+
lines.push(` Worktree patch: \`${entry.worktreePatchPath}\``);
|
|
71
|
+
}
|
|
72
|
+
return lines;
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
return [
|
|
76
|
+
metadata,
|
|
77
|
+
'# Pull Backup',
|
|
78
|
+
'',
|
|
79
|
+
`Created: \`${createdAt}\``,
|
|
80
|
+
`Branch: \`${branch || 'HEAD'}\``,
|
|
81
|
+
`Base ref: \`${headRef || 'none'}\``,
|
|
82
|
+
`Pulled to: \`${targetRef || 'none'}\``,
|
|
83
|
+
`Files backed up: \`${fileCount}\``,
|
|
84
|
+
'',
|
|
85
|
+
'This backup was created because local dirty changes overlapped with incoming remote updates.',
|
|
86
|
+
'The working tree was updated to the remote version so the latest upstream content is visible immediately.',
|
|
87
|
+
'',
|
|
88
|
+
'## Saved Files',
|
|
89
|
+
'',
|
|
90
|
+
...entryLines,
|
|
91
|
+
'',
|
|
92
|
+
'## Recovery',
|
|
93
|
+
'',
|
|
94
|
+
'Compare the current file content against the saved copies above, then manually restore any changes you want to keep.',
|
|
95
|
+
'Use `git apply --cached <staged patch>` to inspect or restore staged/index-only changes on a safe branch.',
|
|
96
|
+
'Use `git apply <worktree patch>` to inspect or restore unstaged worktree changes on a safe branch.',
|
|
97
|
+
'',
|
|
98
|
+
].join('\n');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function ensureLocalGitExclude(vaultDir) {
|
|
102
|
+
const excludePath = resolve(vaultDir, '.git/info/exclude');
|
|
103
|
+
let existingContent = '';
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
existingContent = await readFile(excludePath, 'utf8');
|
|
107
|
+
} catch (error) {
|
|
108
|
+
if (error?.code !== 'ENOENT') {
|
|
109
|
+
throw error;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const lines = existingContent
|
|
114
|
+
.split(/\r?\n/u)
|
|
115
|
+
.map((line) => line.trim())
|
|
116
|
+
.filter(Boolean);
|
|
117
|
+
if (lines.includes('.collabmd') || lines.includes('.collabmd/')) {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const prefix = existingContent.length > 0 && !existingContent.endsWith('\n')
|
|
122
|
+
? '\n'
|
|
123
|
+
: '';
|
|
124
|
+
await writeFile(excludePath, `${existingContent}${prefix}.collabmd/\n`, 'utf8');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export class PullBackupStore {
|
|
128
|
+
constructor({ vaultDir }) {
|
|
129
|
+
this.vaultDir = resolve(vaultDir);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
getStorageRoot() {
|
|
133
|
+
return resolve(this.vaultDir, PULL_BACKUP_STORAGE_ROOT);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
getBackupPath(backupId) {
|
|
137
|
+
return resolve(this.getStorageRoot(), backupId);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
getSummaryPath(backupId) {
|
|
141
|
+
return `${PULL_BACKUP_STORAGE_ROOT}/${backupId}/summary.md`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async createBackup({
|
|
145
|
+
branch = null,
|
|
146
|
+
createdAt = new Date().toISOString(),
|
|
147
|
+
entries = [],
|
|
148
|
+
headRef = null,
|
|
149
|
+
targetRef = null,
|
|
150
|
+
} = {}) {
|
|
151
|
+
await ensureLocalGitExclude(this.vaultDir);
|
|
152
|
+
|
|
153
|
+
const backupId = createBackupId(headRef, createdAt);
|
|
154
|
+
const backupDir = this.getBackupPath(backupId);
|
|
155
|
+
const filesRoot = join(backupDir, 'files');
|
|
156
|
+
const patchesRoot = join(backupDir, 'patches');
|
|
157
|
+
const backupEntries = [];
|
|
158
|
+
|
|
159
|
+
await mkdir(filesRoot, { recursive: true });
|
|
160
|
+
await mkdir(patchesRoot, { recursive: true });
|
|
161
|
+
|
|
162
|
+
for (const entry of entries) {
|
|
163
|
+
const normalizedPath = normalizeRelativePath(entry?.path);
|
|
164
|
+
if (!normalizedPath) {
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const absoluteSourcePath = sanitizeVaultPath(this.vaultDir, normalizedPath);
|
|
169
|
+
const relativeBackupPath = `${PULL_BACKUP_STORAGE_ROOT}/${backupId}/files/${normalizedPath}`;
|
|
170
|
+
const relativeStagedPatchPath = entry?.stagedPatchContent
|
|
171
|
+
? `${PULL_BACKUP_STORAGE_ROOT}/${backupId}/patches/${normalizedPath}.staged.patch`
|
|
172
|
+
: null;
|
|
173
|
+
const relativeWorktreePatchPath = entry?.worktreePatchContent
|
|
174
|
+
? `${PULL_BACKUP_STORAGE_ROOT}/${backupId}/patches/${normalizedPath}.worktree.patch`
|
|
175
|
+
: null;
|
|
176
|
+
let backupPath = null;
|
|
177
|
+
|
|
178
|
+
if (absoluteSourcePath) {
|
|
179
|
+
try {
|
|
180
|
+
await stat(absoluteSourcePath);
|
|
181
|
+
const absoluteBackupPath = resolve(this.vaultDir, relativeBackupPath);
|
|
182
|
+
await mkdir(dirname(absoluteBackupPath), { recursive: true });
|
|
183
|
+
await copyFile(absoluteSourcePath, absoluteBackupPath);
|
|
184
|
+
backupPath = normalizeRelativePath(relativeBackupPath);
|
|
185
|
+
} catch (error) {
|
|
186
|
+
if (error?.code !== 'ENOENT') {
|
|
187
|
+
throw error;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (relativeStagedPatchPath) {
|
|
193
|
+
const absolutePatchPath = resolve(this.vaultDir, relativeStagedPatchPath);
|
|
194
|
+
await mkdir(dirname(absolutePatchPath), { recursive: true });
|
|
195
|
+
await writeFile(absolutePatchPath, entry.stagedPatchContent, 'utf8');
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (relativeWorktreePatchPath) {
|
|
199
|
+
const absolutePatchPath = resolve(this.vaultDir, relativeWorktreePatchPath);
|
|
200
|
+
await mkdir(dirname(absolutePatchPath), { recursive: true });
|
|
201
|
+
await writeFile(absolutePatchPath, entry.worktreePatchContent, 'utf8');
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
backupEntries.push({
|
|
205
|
+
backupPath,
|
|
206
|
+
oldPath: normalizeRelativePath(entry?.oldPath ?? ''),
|
|
207
|
+
path: normalizedPath,
|
|
208
|
+
stagedPatchPath: relativeStagedPatchPath ? normalizeRelativePath(relativeStagedPatchPath) : null,
|
|
209
|
+
worktreePatchPath: relativeWorktreePatchPath ? normalizeRelativePath(relativeWorktreePatchPath) : null,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const fileCount = backupEntries.filter((entry) => (
|
|
214
|
+
entry.backupPath
|
|
215
|
+
|| entry.stagedPatchPath
|
|
216
|
+
|| entry.worktreePatchPath
|
|
217
|
+
)).length;
|
|
218
|
+
const summaryPath = this.getSummaryPath(backupId);
|
|
219
|
+
await writeFile(
|
|
220
|
+
resolve(this.vaultDir, summaryPath),
|
|
221
|
+
formatSummary({
|
|
222
|
+
backupEntries,
|
|
223
|
+
backupId,
|
|
224
|
+
branch,
|
|
225
|
+
createdAt,
|
|
226
|
+
fileCount,
|
|
227
|
+
headRef,
|
|
228
|
+
targetRef,
|
|
229
|
+
}),
|
|
230
|
+
'utf8',
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
return {
|
|
234
|
+
branch,
|
|
235
|
+
createdAt,
|
|
236
|
+
fileCount,
|
|
237
|
+
id: backupId,
|
|
238
|
+
summaryPath,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async listBackups() {
|
|
243
|
+
let entries = [];
|
|
244
|
+
try {
|
|
245
|
+
entries = await readdir(this.getStorageRoot(), { withFileTypes: true });
|
|
246
|
+
} catch (error) {
|
|
247
|
+
if (error?.code === 'ENOENT') {
|
|
248
|
+
return [];
|
|
249
|
+
}
|
|
250
|
+
throw error;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const backups = await Promise.all(entries
|
|
254
|
+
.filter((entry) => entry.isDirectory())
|
|
255
|
+
.map(async (entry) => {
|
|
256
|
+
const summaryPath = this.getSummaryPath(entry.name);
|
|
257
|
+
try {
|
|
258
|
+
const content = await readFile(resolve(this.vaultDir, summaryPath), 'utf8');
|
|
259
|
+
const metadata = parseBackupMetadata(content);
|
|
260
|
+
if (!metadata) {
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return {
|
|
265
|
+
branch: metadata.branch ?? null,
|
|
266
|
+
createdAt: metadata.createdAt ?? null,
|
|
267
|
+
fileCount: Number(metadata.fileCount ?? 0) || 0,
|
|
268
|
+
id: entry.name,
|
|
269
|
+
summaryPath,
|
|
270
|
+
};
|
|
271
|
+
} catch (error) {
|
|
272
|
+
if (error?.code === 'ENOENT') {
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
275
|
+
throw error;
|
|
276
|
+
}
|
|
277
|
+
}));
|
|
278
|
+
|
|
279
|
+
return backups
|
|
280
|
+
.filter(Boolean)
|
|
281
|
+
.sort((left, right) => String(right.createdAt || '').localeCompare(String(left.createdAt || '')));
|
|
282
|
+
}
|
|
283
|
+
}
|
|
@@ -582,6 +582,22 @@ export class VaultFileStore {
|
|
|
582
582
|
]);
|
|
583
583
|
}
|
|
584
584
|
|
|
585
|
+
async reconcileCollaborationSnapshots({
|
|
586
|
+
changedPaths = [],
|
|
587
|
+
deletedPaths = [],
|
|
588
|
+
renamedPaths = [],
|
|
589
|
+
} = {}) {
|
|
590
|
+
const affectedPaths = new Set([
|
|
591
|
+
...(changedPaths ?? []).filter(Boolean),
|
|
592
|
+
...(deletedPaths ?? []).filter(Boolean),
|
|
593
|
+
...((renamedPaths ?? []).flatMap((entry) => [entry?.oldPath, entry?.newPath]).filter(Boolean)),
|
|
594
|
+
]);
|
|
595
|
+
|
|
596
|
+
await Promise.allSettled(
|
|
597
|
+
Array.from(affectedPaths, (filePath) => this.deleteCollaborationSnapshot(filePath)),
|
|
598
|
+
);
|
|
599
|
+
}
|
|
600
|
+
|
|
585
601
|
async countFilesInDir(dirPath) {
|
|
586
602
|
let count = 0;
|
|
587
603
|
|