mstro-app 0.4.50 → 0.4.52
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/dist/server/services/websocket/file-upload-handler.d.ts +6 -2
- package/dist/server/services/websocket/file-upload-handler.d.ts.map +1 -1
- package/dist/server/services/websocket/file-upload-handler.js +127 -46
- package/dist/server/services/websocket/file-upload-handler.js.map +1 -1
- package/dist/server/services/websocket/git-branch-handlers.d.ts +1 -1
- package/dist/server/services/websocket/git-branch-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/git-branch-handlers.js +21 -1
- package/dist/server/services/websocket/git-branch-handlers.js.map +1 -1
- package/dist/server/services/websocket/git-handlers.js +1 -1
- package/dist/server/services/websocket/git-handlers.js.map +1 -1
- package/dist/server/services/websocket/git-worktree-handlers.d.ts +2 -0
- package/dist/server/services/websocket/git-worktree-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/git-worktree-handlers.js +2 -2
- package/dist/server/services/websocket/git-worktree-handlers.js.map +1 -1
- package/dist/server/services/websocket/handler.js +4 -4
- package/dist/server/services/websocket/handler.js.map +1 -1
- package/dist/server/services/websocket/plan-board-handlers.d.ts +1 -0
- package/dist/server/services/websocket/plan-board-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/plan-board-handlers.js +34 -0
- package/dist/server/services/websocket/plan-board-handlers.js.map +1 -1
- package/dist/server/services/websocket/plan-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/plan-handlers.js +2 -1
- package/dist/server/services/websocket/plan-handlers.js.map +1 -1
- package/dist/server/services/websocket/types.d.ts +1 -1
- package/dist/server/services/websocket/types.d.ts.map +1 -1
- package/dist/server/services/websocket/types.js +1 -1
- package/dist/server/services/websocket/types.js.map +1 -1
- package/package.json +1 -1
- package/server/services/websocket/file-upload-handler.ts +159 -51
- package/server/services/websocket/git-branch-handlers.ts +28 -1
- package/server/services/websocket/git-handlers.ts +1 -1
- package/server/services/websocket/git-worktree-handlers.ts +2 -2
- package/server/services/websocket/handler.ts +4 -4
- package/server/services/websocket/plan-board-handlers.ts +42 -0
- package/server/services/websocket/plan-handlers.ts +2 -1
- package/server/services/websocket/types.ts +1 -1
|
@@ -4,13 +4,16 @@
|
|
|
4
4
|
/**
|
|
5
5
|
* Chunked File Upload Handler
|
|
6
6
|
*
|
|
7
|
-
* Receives files in chunks over WebSocket from remote web clients
|
|
8
|
-
*
|
|
7
|
+
* Receives files in chunks over WebSocket from remote web clients.
|
|
8
|
+
* - When `targetPath` is provided: streams to <workingDir>/<targetPath> (drag-drop into file tree).
|
|
9
|
+
* - Otherwise: streams to .mstro/tmp/attachments/{tabId}/ (prompt attachments).
|
|
9
10
|
*/
|
|
10
11
|
|
|
11
12
|
import type { WriteStream } from 'node:fs';
|
|
12
13
|
import { createWriteStream, existsSync, mkdirSync, rmSync, statSync } from 'node:fs';
|
|
13
|
-
import { join } from 'node:path';
|
|
14
|
+
import { dirname, join } from 'node:path';
|
|
15
|
+
import { containsDangerousPatterns, validatePathWithinWorkingDir } from '../pathUtils.js';
|
|
16
|
+
import type { HandlerContext } from './handler-context.js';
|
|
14
17
|
import type { WebSocketResponse, WSContext } from './types.js';
|
|
15
18
|
|
|
16
19
|
interface UploadState {
|
|
@@ -22,6 +25,8 @@ interface UploadState {
|
|
|
22
25
|
totalChunks: number;
|
|
23
26
|
receivedChunks: number;
|
|
24
27
|
filePath: string;
|
|
28
|
+
/** Path relative to workingDir (only set when destination is the file tree, not the attachments dir) */
|
|
29
|
+
targetRelPath?: string;
|
|
25
30
|
stream: WriteStream;
|
|
26
31
|
lastActivity: number;
|
|
27
32
|
}
|
|
@@ -44,7 +49,10 @@ export class FileUploadHandler {
|
|
|
44
49
|
private completedUploads = new Map<string, CompletedUpload[]>(); // tabId -> completed uploads
|
|
45
50
|
private cleanupInterval: ReturnType<typeof setInterval>;
|
|
46
51
|
|
|
47
|
-
constructor(
|
|
52
|
+
constructor(
|
|
53
|
+
private workingDir: string,
|
|
54
|
+
private ctx?: HandlerContext,
|
|
55
|
+
) {
|
|
48
56
|
// Periodically clean up stale uploads
|
|
49
57
|
this.cleanupInterval = setInterval(() => this.cleanupStaleUploads(), 30_000);
|
|
50
58
|
}
|
|
@@ -65,59 +73,48 @@ export class FileUploadHandler {
|
|
|
65
73
|
ws: WSContext,
|
|
66
74
|
send: (ws: WSContext, response: WebSocketResponse) => void,
|
|
67
75
|
tabId: string,
|
|
68
|
-
data: { uploadId: string; fileName: string; fileSize: number; mimeType: string; isImage: boolean; totalChunks: number }
|
|
76
|
+
data: { uploadId: string; fileName: string; fileSize: number; mimeType: string; isImage: boolean; totalChunks: number; targetPath?: string; overwrite?: boolean },
|
|
77
|
+
permission?: 'view',
|
|
69
78
|
): void {
|
|
70
|
-
const { uploadId, fileName, fileSize, mimeType, isImage, totalChunks } = data;
|
|
79
|
+
const { uploadId, fileName, fileSize, mimeType, isImage, totalChunks, targetPath, overwrite } = data;
|
|
80
|
+
const sendError = (error: string) => send(ws, {
|
|
81
|
+
type: 'fileUploadError' as WebSocketResponse['type'],
|
|
82
|
+
tabId,
|
|
83
|
+
data: { uploadId, error },
|
|
84
|
+
});
|
|
71
85
|
|
|
72
|
-
// Validate file size
|
|
73
86
|
if (fileSize > MAX_FILE_SIZE) {
|
|
74
|
-
|
|
75
|
-
type: 'fileUploadError' as WebSocketResponse['type'],
|
|
76
|
-
tabId,
|
|
77
|
-
data: { uploadId, error: `File too large: ${(fileSize / 1024 / 1024).toFixed(1)}MB exceeds ${MAX_FILE_SIZE / 1024 / 1024}MB limit` }
|
|
78
|
-
});
|
|
87
|
+
sendError(`File too large: ${(fileSize / 1024 / 1024).toFixed(1)}MB exceeds ${MAX_FILE_SIZE / 1024 / 1024}MB limit`);
|
|
79
88
|
return;
|
|
80
89
|
}
|
|
81
90
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
mkdirSync(attachDir, { recursive: true });
|
|
86
|
-
}
|
|
91
|
+
const resolved = targetPath !== undefined
|
|
92
|
+
? resolveFileTreeTarget({ targetPath, fileName, workingDir: this.workingDir, permission, overwrite })
|
|
93
|
+
: resolveAttachmentTarget({ workingDir: this.workingDir, tabId, fileName });
|
|
87
94
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
while (existsSync(join(attachDir, targetFileName))) {
|
|
92
|
-
const ext = fileName.lastIndexOf('.') !== -1 ? fileName.slice(fileName.lastIndexOf('.')) : '';
|
|
93
|
-
const base = fileName.lastIndexOf('.') !== -1 ? fileName.slice(0, fileName.lastIndexOf('.')) : fileName;
|
|
94
|
-
targetFileName = `${base}-${counter}${ext}`;
|
|
95
|
-
counter++;
|
|
95
|
+
if (!resolved.ok) {
|
|
96
|
+
sendError(resolved.error);
|
|
97
|
+
return;
|
|
96
98
|
}
|
|
97
99
|
|
|
98
|
-
|
|
99
|
-
const stream = createWriteStream(filePath);
|
|
100
|
-
|
|
101
|
-
const uploadState: UploadState = {
|
|
100
|
+
this.activeUploads.set(uploadId, {
|
|
102
101
|
uploadId,
|
|
103
|
-
fileName:
|
|
102
|
+
fileName: resolved.fileName,
|
|
104
103
|
fileSize,
|
|
105
104
|
mimeType,
|
|
106
105
|
isImage,
|
|
107
106
|
totalChunks,
|
|
108
107
|
receivedChunks: 0,
|
|
109
|
-
filePath,
|
|
110
|
-
|
|
108
|
+
filePath: resolved.filePath,
|
|
109
|
+
targetRelPath: resolved.targetRelPath,
|
|
110
|
+
stream: createWriteStream(resolved.filePath),
|
|
111
111
|
lastActivity: Date.now(),
|
|
112
|
-
};
|
|
113
|
-
|
|
114
|
-
this.activeUploads.set(uploadId, uploadState);
|
|
112
|
+
});
|
|
115
113
|
|
|
116
|
-
// Send ack for start
|
|
117
114
|
send(ws, {
|
|
118
115
|
type: 'fileUploadAck' as WebSocketResponse['type'],
|
|
119
116
|
tabId,
|
|
120
|
-
data: { uploadId, chunkIndex: -1, status: 'ok' }
|
|
117
|
+
data: { uploadId, chunkIndex: -1, status: 'ok' },
|
|
121
118
|
});
|
|
122
119
|
}
|
|
123
120
|
|
|
@@ -183,27 +180,50 @@ export class FileUploadHandler {
|
|
|
183
180
|
// Verify file was written
|
|
184
181
|
try {
|
|
185
182
|
const stat = statSync(upload.filePath);
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
183
|
+
|
|
184
|
+
// Only stash as a pending prompt attachment for the attachment-path case.
|
|
185
|
+
// File-tree drops land at the final destination directly.
|
|
186
|
+
if (upload.targetRelPath === undefined) {
|
|
187
|
+
const completed: CompletedUpload = {
|
|
188
|
+
uploadId,
|
|
189
|
+
fileName: upload.fileName,
|
|
190
|
+
filePath: upload.filePath,
|
|
191
|
+
isImage: upload.isImage,
|
|
192
|
+
mimeType: upload.mimeType,
|
|
193
|
+
fileSize: stat.size,
|
|
194
|
+
};
|
|
195
|
+
const tabUploads = this.completedUploads.get(tabId) || [];
|
|
196
|
+
tabUploads.push(completed);
|
|
197
|
+
this.completedUploads.set(tabId, tabUploads);
|
|
198
|
+
}
|
|
199
199
|
|
|
200
200
|
this.activeUploads.delete(uploadId);
|
|
201
201
|
|
|
202
202
|
send(ws, {
|
|
203
203
|
type: 'fileUploadReady' as WebSocketResponse['type'],
|
|
204
204
|
tabId,
|
|
205
|
-
data: {
|
|
205
|
+
data: {
|
|
206
|
+
uploadId,
|
|
207
|
+
filePath: upload.filePath,
|
|
208
|
+
fileName: upload.fileName,
|
|
209
|
+
...(upload.targetRelPath !== undefined ? { targetPath: upload.targetRelPath } : {}),
|
|
210
|
+
},
|
|
206
211
|
});
|
|
212
|
+
|
|
213
|
+
// Broadcast fileCreated so all connected clients (including the uploader)
|
|
214
|
+
// refresh their file tree. The final path may differ from the requested
|
|
215
|
+
// targetPath due to counter-based disambiguation, so we broadcast the resolved path.
|
|
216
|
+
if (upload.targetRelPath !== undefined && this.ctx) {
|
|
217
|
+
this.ctx.broadcastToAll({
|
|
218
|
+
type: 'fileCreated' as WebSocketResponse['type'],
|
|
219
|
+
data: {
|
|
220
|
+
path: upload.targetRelPath,
|
|
221
|
+
name: upload.fileName,
|
|
222
|
+
size: stat.size,
|
|
223
|
+
modifiedAt: new Date().toISOString(),
|
|
224
|
+
},
|
|
225
|
+
});
|
|
226
|
+
}
|
|
207
227
|
} catch (err) {
|
|
208
228
|
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
209
229
|
send(ws, {
|
|
@@ -257,3 +277,91 @@ export class FileUploadHandler {
|
|
|
257
277
|
}
|
|
258
278
|
}
|
|
259
279
|
}
|
|
280
|
+
|
|
281
|
+
type ResolveResult =
|
|
282
|
+
| { ok: true; filePath: string; fileName: string; targetRelPath?: string }
|
|
283
|
+
| { ok: false; error: string };
|
|
284
|
+
|
|
285
|
+
function splitExt(fileName: string): { base: string; ext: string } {
|
|
286
|
+
const dot = fileName.lastIndexOf('.');
|
|
287
|
+
if (dot <= 0) return { base: fileName, ext: '' };
|
|
288
|
+
return { base: fileName.slice(0, dot), ext: fileName.slice(dot) };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function ensureDir(dir: string): void {
|
|
292
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function resolveFileTreeTarget(opts: {
|
|
296
|
+
targetPath: string;
|
|
297
|
+
fileName: string;
|
|
298
|
+
workingDir: string;
|
|
299
|
+
permission?: 'view';
|
|
300
|
+
overwrite?: boolean;
|
|
301
|
+
}): ResolveResult {
|
|
302
|
+
const { targetPath, fileName, workingDir, permission, overwrite } = opts;
|
|
303
|
+
|
|
304
|
+
if (permission === 'view') {
|
|
305
|
+
return { ok: false, error: 'View-only sessions cannot upload files' };
|
|
306
|
+
}
|
|
307
|
+
if (containsDangerousPatterns(targetPath) || containsDangerousPatterns(fileName)) {
|
|
308
|
+
return { ok: false, error: 'Invalid characters in target path' };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const validation = validatePathWithinWorkingDir(targetPath, workingDir);
|
|
312
|
+
if (!validation.valid) {
|
|
313
|
+
return { ok: false, error: validation.error ?? 'Invalid target path' };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const resolvedPath = validation.resolvedPath;
|
|
317
|
+
const parent = dirname(resolvedPath);
|
|
318
|
+
try {
|
|
319
|
+
ensureDir(parent);
|
|
320
|
+
} catch (err) {
|
|
321
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
322
|
+
return { ok: false, error: `Failed to create parent directory: ${msg}` };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const lastSlash = targetPath.lastIndexOf('/');
|
|
326
|
+
const relDir = lastSlash >= 0 ? targetPath.slice(0, lastSlash) : '';
|
|
327
|
+
|
|
328
|
+
let finalName = fileName;
|
|
329
|
+
let finalAbs = resolvedPath;
|
|
330
|
+
if (existsSync(finalAbs) && !overwrite) {
|
|
331
|
+
const { base, ext } = splitExt(fileName);
|
|
332
|
+
let counter = 1;
|
|
333
|
+
do {
|
|
334
|
+
finalName = `${base} (${counter})${ext}`;
|
|
335
|
+
finalAbs = join(parent, finalName);
|
|
336
|
+
counter++;
|
|
337
|
+
} while (existsSync(finalAbs));
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const finalRel = relDir ? `${relDir}/${finalName}` : finalName;
|
|
341
|
+
return { ok: true, filePath: finalAbs, fileName: finalName, targetRelPath: finalRel };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function resolveAttachmentTarget(opts: {
|
|
345
|
+
workingDir: string;
|
|
346
|
+
tabId: string;
|
|
347
|
+
fileName: string;
|
|
348
|
+
}): ResolveResult {
|
|
349
|
+
const { workingDir, tabId, fileName } = opts;
|
|
350
|
+
const attachDir = join(workingDir, '.mstro', 'tmp', 'attachments', tabId);
|
|
351
|
+
try {
|
|
352
|
+
ensureDir(attachDir);
|
|
353
|
+
} catch (err) {
|
|
354
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
355
|
+
return { ok: false, error: `Failed to create attachment directory: ${msg}` };
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
let finalName = fileName;
|
|
359
|
+
let counter = 1;
|
|
360
|
+
while (existsSync(join(attachDir, finalName))) {
|
|
361
|
+
const { base, ext } = splitExt(fileName);
|
|
362
|
+
finalName = `${base}-${counter}${ext}`;
|
|
363
|
+
counter++;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
return { ok: true, filePath: join(attachDir, finalName), fileName: finalName };
|
|
367
|
+
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// Licensed under the MIT License. See LICENSE file for details.
|
|
3
3
|
|
|
4
4
|
import { executeGitCommand, sendGitError } from './git-utils.js';
|
|
5
|
+
import { findWorktreePathForBranch, handleTabWorktreeSwitch } from './git-worktree-handlers.js';
|
|
5
6
|
import type { HandlerContext } from './handler-context.js';
|
|
6
7
|
import type { GitBranchEntry, WebSocketMessage, WSContext } from './types.js';
|
|
7
8
|
|
|
@@ -40,7 +41,28 @@ export async function handleGitListBranches(ctx: HandlerContext, ws: WSContext,
|
|
|
40
41
|
}
|
|
41
42
|
}
|
|
42
43
|
|
|
43
|
-
|
|
44
|
+
/**
|
|
45
|
+
* If `branch` is already checked out in a worktree other than `workingDir`, redirect the tab
|
|
46
|
+
* there via the worktree-switch flow and return true. Git refuses `checkout` in this case, so
|
|
47
|
+
* treating it as a view change is both correct and the only thing that works.
|
|
48
|
+
*/
|
|
49
|
+
async function redirectToWorktreeIfBranchCheckedOut(
|
|
50
|
+
ctx: HandlerContext, ws: WSContext,
|
|
51
|
+
tabId: string, branch: string, workingDir: string, rootWorkingDir: string,
|
|
52
|
+
): Promise<boolean> {
|
|
53
|
+
const wtListResult = await executeGitCommand(['worktree', 'list', '--porcelain'], workingDir);
|
|
54
|
+
if (wtListResult.exitCode !== 0) return false;
|
|
55
|
+
const existingWt = findWorktreePathForBranch(wtListResult.stdout, branch);
|
|
56
|
+
if (!existingWt || existingWt === workingDir) return false;
|
|
57
|
+
await handleTabWorktreeSwitch(
|
|
58
|
+
ctx, ws,
|
|
59
|
+
{ type: 'tabWorktreeSwitch', tabId, data: { tabId, worktreePath: existingWt } },
|
|
60
|
+
tabId, rootWorkingDir,
|
|
61
|
+
);
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function handleGitCheckout(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string, rootWorkingDir: string): Promise<void> {
|
|
44
66
|
try {
|
|
45
67
|
const { branch, create, startPoint } = msg.data || {};
|
|
46
68
|
if (!branch) {
|
|
@@ -48,6 +70,11 @@ export async function handleGitCheckout(ctx: HandlerContext, ws: WSContext, msg:
|
|
|
48
70
|
return;
|
|
49
71
|
}
|
|
50
72
|
|
|
73
|
+
// Skip the worktree redirect for `create` — a name collision there is a real user error.
|
|
74
|
+
if (!create && await redirectToWorktreeIfBranchCheckedOut(ctx, ws, tabId, branch, workingDir, rootWorkingDir)) {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
51
78
|
const statusResult = await executeGitCommand(['status', '--porcelain'], workingDir);
|
|
52
79
|
if (statusResult.stdout.trim()) {
|
|
53
80
|
ctx.send(ws, { type: 'gitError', tabId, data: { error: 'Commit or stash changes before switching branches' } });
|
|
@@ -53,7 +53,7 @@ export async function handleGitMessage(ctx: HandlerContext, ws: WSContext, msg:
|
|
|
53
53
|
gitDiscoverRepos: () => handleGitDiscoverRepos(ctx, ws, tabId, workingDir),
|
|
54
54
|
gitSetDirectory: () => handleGitSetDirectory(ctx, ws, msg, tabId, workingDir),
|
|
55
55
|
gitListBranches: () => handleGitListBranches(ctx, ws, tabId, gitDir),
|
|
56
|
-
gitCheckout: () => handleGitCheckout(ctx, ws, msg, tabId, gitDir),
|
|
56
|
+
gitCheckout: () => handleGitCheckout(ctx, ws, msg, tabId, gitDir, workingDir),
|
|
57
57
|
gitCreateBranch: () => handleGitCreateBranch(ctx, ws, msg, tabId, gitDir),
|
|
58
58
|
gitDeleteBranch: () => handleGitDeleteBranch(ctx, ws, msg, tabId, gitDir),
|
|
59
59
|
gitDiff: () => handleGitDiff(ctx, ws, msg, tabId, gitDir),
|
|
@@ -201,7 +201,7 @@ async function handleGitWorktreeRemove(ctx: HandlerContext, ws: WSContext, msg:
|
|
|
201
201
|
}
|
|
202
202
|
}
|
|
203
203
|
|
|
204
|
-
async function handleTabWorktreeSwitch(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string): Promise<void> {
|
|
204
|
+
export async function handleTabWorktreeSwitch(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string): Promise<void> {
|
|
205
205
|
try {
|
|
206
206
|
const { tabId: targetTabId, worktreePath } = msg.data || {};
|
|
207
207
|
const resolvedTabId = targetTabId || tabId;
|
|
@@ -445,7 +445,7 @@ async function cleanupAfterMerge(
|
|
|
445
445
|
return { warnings, removedWorktreePath };
|
|
446
446
|
}
|
|
447
447
|
|
|
448
|
-
function findWorktreePathForBranch(porcelainOutput: string, branchName: string): string | null {
|
|
448
|
+
export function findWorktreePathForBranch(porcelainOutput: string, branchName: string): string | null {
|
|
449
449
|
let currentWtPath = '';
|
|
450
450
|
const fullRef = `refs/heads/${branchName}`;
|
|
451
451
|
for (const line of porcelainOutput.split('\n')) {
|
|
@@ -220,20 +220,20 @@ export class WebSocketImproviseHandler implements HandlerContext {
|
|
|
220
220
|
case 'git': return handleGitMessage(this, ws, msg, tabId, workingDir);
|
|
221
221
|
case 'quality': return handleQualityMessage(this, ws, msg, tabId, workingDir, permission);
|
|
222
222
|
case 'plan': return handlePlanMessage(this, ws, msg, tabId, workingDir, permission);
|
|
223
|
-
case 'fileUpload': return this.handleFileUploadMessage(ws, msg, tabId, workingDir);
|
|
223
|
+
case 'fileUpload': return this.handleFileUploadMessage(ws, msg, tabId, workingDir, permission);
|
|
224
224
|
}
|
|
225
225
|
}
|
|
226
226
|
|
|
227
|
-
private handleFileUploadMessage(ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string): void {
|
|
227
|
+
private handleFileUploadMessage(ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string, permission?: 'view'): void {
|
|
228
228
|
if (!this.fileUploadHandler) {
|
|
229
|
-
this.fileUploadHandler = new FileUploadHandler(workingDir);
|
|
229
|
+
this.fileUploadHandler = new FileUploadHandler(workingDir, this);
|
|
230
230
|
}
|
|
231
231
|
const handler = this.fileUploadHandler;
|
|
232
232
|
const send = this.send.bind(this);
|
|
233
233
|
|
|
234
234
|
switch (msg.type) {
|
|
235
235
|
case 'fileUploadStart':
|
|
236
|
-
handler.handleUploadStart(ws, send, tabId, msg.data);
|
|
236
|
+
handler.handleUploadStart(ws, send, tabId, msg.data, permission);
|
|
237
237
|
break;
|
|
238
238
|
case 'fileUploadChunk':
|
|
239
239
|
handler.handleUploadChunk(ws, send, tabId, msg.data);
|
|
@@ -209,6 +209,48 @@ export function handleArchiveBoard(
|
|
|
209
209
|
}
|
|
210
210
|
}
|
|
211
211
|
|
|
212
|
+
export function handleRestoreBoard(
|
|
213
|
+
ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage,
|
|
214
|
+
workingDir: string, permission?: 'view',
|
|
215
|
+
): void {
|
|
216
|
+
if (denyIfViewOnly(ctx, ws, permission)) return;
|
|
217
|
+
|
|
218
|
+
const boardId = msg.data?.boardId;
|
|
219
|
+
if (!boardId) {
|
|
220
|
+
ctx.send(ws, { type: 'planError', data: { error: 'Board ID required' } });
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const pmDir = resolvePmDir(workingDir);
|
|
225
|
+
if (!pmDir) return;
|
|
226
|
+
|
|
227
|
+
const boardMdPath = join(pmDir, 'boards', boardId, 'board.md');
|
|
228
|
+
if (!existsSync(boardMdPath)) {
|
|
229
|
+
ctx.send(ws, { type: 'planError', data: { error: `Board not found: ${boardId}` } });
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
let content = readFileSync(boardMdPath, 'utf-8');
|
|
234
|
+
content = replaceFrontMatterField(content, 'status', 'active');
|
|
235
|
+
writeFileSync(boardMdPath, content, 'utf-8');
|
|
236
|
+
|
|
237
|
+
const workspacePath = join(pmDir, 'workspace.json');
|
|
238
|
+
if (existsSync(workspacePath)) {
|
|
239
|
+
const workspace: Workspace = JSON.parse(readFileSync(workspacePath, 'utf-8'));
|
|
240
|
+
if (!workspace.boardOrder.includes(boardId)) {
|
|
241
|
+
workspace.boardOrder.push(boardId);
|
|
242
|
+
}
|
|
243
|
+
workspace.activeBoardId = boardId;
|
|
244
|
+
writeFileSync(workspacePath, JSON.stringify(workspace, null, 2), 'utf-8');
|
|
245
|
+
ctx.broadcastToAll({ type: 'planWorkspaceUpdated', data: workspace });
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const boardState = parseBoardDirectory(pmDir, boardId);
|
|
249
|
+
if (boardState) {
|
|
250
|
+
ctx.broadcastToAll({ type: 'planBoardUpdated', data: boardState.board });
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
212
254
|
export function handleGetBoard(
|
|
213
255
|
ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage,
|
|
214
256
|
workingDir: string,
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import type { HandlerContext } from './handler-context.js';
|
|
12
|
-
import { handleArchiveBoard, handleChatToBoard, handleCreateBoard, handleGetBoard, handleGetBoardArtifacts, handleGetBoardState, handleReorderBoards, handleSetActiveBoard, handleUpdateBoard } from './plan-board-handlers.js';
|
|
12
|
+
import { handleArchiveBoard, handleChatToBoard, handleCreateBoard, handleGetBoard, handleGetBoardArtifacts, handleGetBoardState, handleReorderBoards, handleRestoreBoard, handleSetActiveBoard, handleUpdateBoard } from './plan-board-handlers.js';
|
|
13
13
|
import { handleExecute, handleExecuteEpic, handlePause, handlePrompt, handleResume, handleStop } from './plan-execution-handlers.js';
|
|
14
14
|
import { handleCreateIssue, handleDeleteIssue, handleGetIssue, handleGetMilestone, handleGetSprint, handleListIssues, handlePlanInit, handleScaffold, handleUpdateIssue } from './plan-issue-handlers.js';
|
|
15
15
|
import { handleActivateSprint, handleCompleteSprint, handleCreateSprint, handleGetSprintArtifacts } from './plan-sprint-handlers.js';
|
|
@@ -47,6 +47,7 @@ export function handlePlanMessage(
|
|
|
47
47
|
planCreateBoard: () => handleCreateBoard(ctx, ws, msg, workingDir, permission),
|
|
48
48
|
planUpdateBoard: () => handleUpdateBoard(ctx, ws, msg, workingDir, permission),
|
|
49
49
|
planArchiveBoard: () => handleArchiveBoard(ctx, ws, msg, workingDir, permission),
|
|
50
|
+
planRestoreBoard: () => handleRestoreBoard(ctx, ws, msg, workingDir, permission),
|
|
50
51
|
planGetBoard: () => handleGetBoard(ctx, ws, msg, workingDir),
|
|
51
52
|
planGetBoardState: () => handleGetBoardState(ctx, ws, msg, workingDir),
|
|
52
53
|
planReorderBoards: () => handleReorderBoards(ctx, ws, msg, workingDir, permission),
|
|
@@ -48,7 +48,7 @@ const FileUploadMessages = ['fileUploadStart', 'fileUploadChunk', 'fileUploadCom
|
|
|
48
48
|
|
|
49
49
|
const PlanMessages = ['planInit', 'planGetState', 'planListIssues', 'planGetIssue', 'planGetSprint', 'planGetMilestone', 'planCreateIssue', 'planUpdateIssue', 'planDeleteIssue', 'planScaffold', 'planPrompt', 'planExecute', 'planExecuteEpic', 'planPause', 'planStop', 'planResume'] as const;
|
|
50
50
|
|
|
51
|
-
const PlanBoardMessages = ['planCreateBoard', 'planUpdateBoard', 'planArchiveBoard', 'planGetBoard', 'planGetBoardState', 'planReorderBoards', 'planSetActiveBoard', 'planGetBoardArtifacts'] as const;
|
|
51
|
+
const PlanBoardMessages = ['planCreateBoard', 'planUpdateBoard', 'planArchiveBoard', 'planRestoreBoard', 'planGetBoard', 'planGetBoardState', 'planReorderBoards', 'planSetActiveBoard', 'planGetBoardArtifacts'] as const;
|
|
52
52
|
|
|
53
53
|
const PlanSprintMessages = ['planCreateSprint', 'planActivateSprint', 'planCompleteSprint', 'planGetSprintArtifacts'] as const;
|
|
54
54
|
|