Skip to content

Commit 5c9f287

Browse files
feat: improve explorer attachments and workspace file flows
- attach folders from the file explorer through @ actions and drag payloads instead of falling back to plain composer path text - carry folder attachment metadata through desktop and runtime contracts so prompts can reference attached folders without flattening their contents - guard workspace-scoped preview, watch, and listing calls against stale roots during startup and workspace switches - clear broken file surfaces when open files are deleted or invalidated instead of leaving stale preview errors mounted - add explorer copy, cut, and paste with a new workspace-scoped filesystem copy IPC path and matching context-menu and keyboard wiring - tighten explorer drag and drop behavior, including root-drop cleanup so highlight state does not stick after moves, and simplify the drag preview pill - expand source-based desktop and runtime coverage for the new attachment, surface, and explorer filesystem flows - validation: `cd desktop && npm run typecheck` - validation: `cd desktop && node --test electron/file-explorer-spreadsheet-preview.test.mjs src/components/panes/FileExplorerPane.test.mjs src/components/layout/AppShell.test.mjs src/components/panes/ChatPaneComposerPrefill.test.mjs src/components/panes/InternalSurfacePane.test.mjs` - validation: `cd runtime/api-server && node --import tsx --test src/app.test.ts` - validation: `cd runtime/harness-host && node --import tsx --test src/pi.test.ts`
1 parent bc83d81 commit 5c9f287

22 files changed

+1043
-176
lines changed

desktop/electron/file-explorer-spreadsheet-preview.test.mjs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,23 @@ test("desktop file explorer enforces the selected workspace root as a filesystem
140140
source,
141141
/"fs:renamePath"[\s\S]*targetPath: string,[\s\S]*nextName: string,[\s\S]*renameExplorerPath\(targetPath, nextName, workspaceId\)/,
142142
);
143+
assert.match(source, /async function copyExplorerPath\(/);
144+
assert.match(
145+
source,
146+
/assertWorkspaceExplorerPathModifiable\(workspaceRoot, destinationAbsolutePath\);/,
147+
);
148+
assert.match(
149+
source,
150+
/await nextAvailableExplorerCreatePath\(\s*destinationAbsolutePath,\s*path\.basename\(sourceAbsolutePath\),\s*\)/,
151+
);
152+
assert.match(
153+
source,
154+
/await fs\.cp\(sourceAbsolutePath, nextAbsolutePath, \{\s*recursive: sourceStat\.isDirectory\(\),[\s\S]*preserveTimestamps: true,[\s\S]*verbatimSymlinks: true,[\s\S]*\}\);/,
155+
);
156+
assert.match(
157+
source,
158+
/"fs:copyPath"[\s\S]*sourcePath: string,[\s\S]*destinationDirectoryPath: string,[\s\S]*copyExplorerPath\(sourcePath, destinationDirectoryPath, workspaceId\)/,
159+
);
143160
assert.match(
144161
source,
145162
/"fs:movePath"[\s\S]*sourcePath: string,[\s\S]*destinationDirectoryPath: string,[\s\S]*moveExplorerPath\(sourcePath, destinationDirectoryPath, workspaceId\)/,
@@ -179,6 +196,10 @@ test("desktop preload exposes file preview watch subscriptions and change events
179196
source,
180197
/unwatchFile: \(subscriptionId: string\) =>\s*ipcRenderer\.invoke\("fs:unwatchFile", subscriptionId\) as Promise<void>/,
181198
);
199+
assert.match(
200+
source,
201+
/copyPath: \(\s*sourcePath: string,\s*destinationDirectoryPath: string,\s*workspaceId\?: string \| null,\s*\) =>[\s\S]*ipcRenderer\.invoke\("fs:copyPath", sourcePath, destinationDirectoryPath, workspaceId\) as Promise<FileSystemMutationPayload>/,
202+
);
182203
assert.match(
183204
source,
184205
/movePath: \(\s*sourcePath: string,\s*destinationDirectoryPath: string,\s*workspaceId\?: string \| null,\s*\) =>[\s\S]*ipcRenderer\.invoke\("fs:movePath", sourcePath, destinationDirectoryPath, workspaceId\) as Promise<FileSystemMutationPayload>/,

desktop/electron/main.ts

Lines changed: 124 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2577,7 +2577,7 @@ interface SessionHistoryMessagePayload {
25772577

25782578
interface SessionInputAttachmentPayload {
25792579
id: string;
2580-
kind: "image" | "file";
2580+
kind: "image" | "file" | "folder";
25812581
name: string;
25822582
mime_type: string;
25832583
size_bytes: number;
@@ -2599,6 +2599,7 @@ interface StageSessionAttachmentPathPayload {
25992599
absolute_path: string;
26002600
name?: string | null;
26012601
mime_type?: string | null;
2602+
kind?: "image" | "file" | "folder" | null;
26022603
}
26032604

26042605
interface StageSessionAttachmentPathsPayload {
@@ -11580,6 +11581,21 @@ function attachmentKind(mimeType: string): "image" | "file" {
1158011581
return mimeType.startsWith("image/") ? "image" : "file";
1158111582
}
1158211583

11584+
function relativeWorkspaceAttachmentPath(
11585+
workspaceDir: string,
11586+
absolutePath: string,
11587+
): string {
11588+
const relativePath = path.relative(workspaceDir, absolutePath);
11589+
if (
11590+
!relativePath ||
11591+
relativePath.startsWith("..") ||
11592+
path.isAbsolute(relativePath)
11593+
) {
11594+
throw new Error("Folder attachments must stay inside the workspace.");
11595+
}
11596+
return relativePath.split(path.sep).join(path.posix.sep);
11597+
}
11598+
1158311599
function resolveWorkspaceMaterializedFilePath(
1158411600
workspaceRoot: string,
1158511601
relativePath: string,
@@ -11736,16 +11752,8 @@ async function stageSessionAttachmentPaths(
1173611752
await fs.mkdir(workspaceDir, { recursive: true });
1173711753

1173811754
const batchId = randomUUID();
11739-
const relativeRoot = path.posix.join(
11740-
".holaboss",
11741-
"input-attachments",
11742-
batchId,
11743-
);
11744-
const absoluteRoot = resolveWorkspaceMaterializedFilePath(
11745-
workspaceDir,
11746-
relativeRoot,
11747-
);
11748-
await fs.mkdir(absoluteRoot, { recursive: true });
11755+
let relativeRoot: string | null = null;
11756+
let absoluteRoot: string | null = null;
1174911757

1175011758
const usedNames = new Set<string>();
1175111759
const attachments: SessionInputAttachmentPayload[] = [];
@@ -11759,10 +11767,52 @@ async function stageSessionAttachmentPaths(
1175911767
}
1176011768

1176111769
const stat = await fs.stat(absolutePath);
11770+
const requestedKind =
11771+
file?.kind === "folder"
11772+
? "folder"
11773+
: file?.kind === "image"
11774+
? "image"
11775+
: "file";
11776+
11777+
if (requestedKind === "folder") {
11778+
if (!stat.isDirectory()) {
11779+
throw new Error(`files[${index}] must reference a folder`);
11780+
}
11781+
11782+
attachments.push({
11783+
id: randomUUID(),
11784+
kind: "folder",
11785+
name:
11786+
sanitizeAttachmentName(file?.name ?? path.basename(absolutePath)) ||
11787+
path.basename(absolutePath) ||
11788+
"Folder",
11789+
mime_type: "inode/directory",
11790+
size_bytes: 0,
11791+
workspace_path: relativeWorkspaceAttachmentPath(
11792+
workspaceDir,
11793+
absolutePath,
11794+
),
11795+
});
11796+
continue;
11797+
}
11798+
1176211799
if (!stat.isFile()) {
1176311800
throw new Error(`files[${index}] must reference a file`);
1176411801
}
1176511802

11803+
if (!relativeRoot || !absoluteRoot) {
11804+
relativeRoot = path.posix.join(
11805+
".holaboss",
11806+
"input-attachments",
11807+
batchId,
11808+
);
11809+
absoluteRoot = resolveWorkspaceMaterializedFilePath(
11810+
workspaceDir,
11811+
relativeRoot,
11812+
);
11813+
await fs.mkdir(absoluteRoot, { recursive: true });
11814+
}
11815+
1176611816
const name = dedupeAttachmentName(
1176711817
sanitizeAttachmentName(file?.name ?? path.basename(absolutePath)),
1176811818
usedNames,
@@ -15988,6 +16038,59 @@ async function moveExplorerPath(
1598816038
};
1598916039
}
1599016040

16041+
async function copyExplorerPath(
16042+
sourcePath: string,
16043+
destinationDirectoryPath: string,
16044+
workspaceId?: string | null,
16045+
): Promise<FileSystemMutationPayload> {
16046+
const { absolutePath: sourceAbsolutePath, workspaceRoot } =
16047+
await resolveWorkspaceScopedExplorerPath(sourcePath, workspaceId);
16048+
const { absolutePath: destinationAbsolutePath } =
16049+
await resolveWorkspaceScopedExplorerPath(
16050+
destinationDirectoryPath,
16051+
workspaceId,
16052+
);
16053+
16054+
const sourceStat = await fs.stat(sourceAbsolutePath);
16055+
const destinationStat = await fs.stat(destinationAbsolutePath);
16056+
if (!destinationStat.isDirectory()) {
16057+
throw new Error("Destination is not a directory.");
16058+
}
16059+
if (
16060+
workspaceRoot &&
16061+
path.normalize(sourceAbsolutePath) === path.normalize(workspaceRoot)
16062+
) {
16063+
throw new Error("Workspace root cannot be copied.");
16064+
}
16065+
assertWorkspaceExplorerPathModifiable(workspaceRoot, destinationAbsolutePath);
16066+
if (
16067+
sourceStat.isDirectory() &&
16068+
isSameOrDescendantPath(sourceAbsolutePath, destinationAbsolutePath)
16069+
) {
16070+
throw new Error("Cannot copy a folder into itself.");
16071+
}
16072+
16073+
const nextAbsolutePath = await nextAvailableExplorerCreatePath(
16074+
destinationAbsolutePath,
16075+
path.basename(sourceAbsolutePath),
16076+
);
16077+
if (workspaceRoot && !isPathWithinRoot(workspaceRoot, nextAbsolutePath)) {
16078+
throw new Error("Copied path escapes workspace root.");
16079+
}
16080+
16081+
await fs.cp(sourceAbsolutePath, nextAbsolutePath, {
16082+
recursive: sourceStat.isDirectory(),
16083+
errorOnExist: true,
16084+
force: false,
16085+
preserveTimestamps: true,
16086+
verbatimSymlinks: true,
16087+
});
16088+
16089+
return {
16090+
absolutePath: nextAbsolutePath,
16091+
};
16092+
}
16093+
1599116094
async function deleteExplorerPath(
1599216095
targetPath: string,
1599316096
workspaceId?: string | null,
@@ -19276,6 +19379,16 @@ app.whenReady().then(async () => {
1927619379
workspaceId?: string | null,
1927719380
) => moveExplorerPath(sourcePath, destinationDirectoryPath, workspaceId),
1927819381
);
19382+
handleTrustedIpc(
19383+
"fs:copyPath",
19384+
["main"],
19385+
async (
19386+
_event,
19387+
sourcePath: string,
19388+
destinationDirectoryPath: string,
19389+
workspaceId?: string | null,
19390+
) => copyExplorerPath(sourcePath, destinationDirectoryPath, workspaceId),
19391+
);
1927919392
handleTrustedIpc(
1928019393
"fs:deletePath",
1928119394
["main"],

desktop/electron/preload.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1047,6 +1047,12 @@ contextBridge.exposeInMainWorld("electronAPI", {
10471047
) as Promise<ExplorerExternalImportResultPayload>,
10481048
renamePath: (targetPath: string, nextName: string, workspaceId?: string | null) =>
10491049
ipcRenderer.invoke("fs:renamePath", targetPath, nextName, workspaceId) as Promise<FileSystemMutationPayload>,
1050+
copyPath: (
1051+
sourcePath: string,
1052+
destinationDirectoryPath: string,
1053+
workspaceId?: string | null,
1054+
) =>
1055+
ipcRenderer.invoke("fs:copyPath", sourcePath, destinationDirectoryPath, workspaceId) as Promise<FileSystemMutationPayload>,
10501056
movePath: (
10511057
sourcePath: string,
10521058
destinationDirectoryPath: string,

desktop/src/components/layout/AppShell.test.mjs

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -644,21 +644,20 @@ test("app shell can route explorer references into chat attachments or text pref
644644
);
645645
assert.match(
646646
source,
647-
/const handleReferenceWorkspacePathInChat = useCallback\(\s*\(entry: LocalFileEntry, referenceText: string\) => \{/,
647+
/const handleReferenceWorkspacePathInChat = useCallback\(\s*\(entry: LocalFileEntry\) => \{/,
648648
);
649-
assert.match(source, /const normalizedReferenceText = referenceText\.trim\(\);/);
650649
assert.match(source, /const normalizedAbsolutePath = entry\.absolutePath\.trim\(\);/);
651650
assert.match(source, /const normalizedName = entry\.name\.trim\(\);/);
652651
assert.match(
653652
source,
654-
/if \(\s*\(entry\.isDirectory && !normalizedReferenceText\) \|\|\s*\(!entry\.isDirectory && \(!normalizedAbsolutePath \|\| !normalizedName\)\)\s*\) \{\s*return;\s*\}/,
653+
/if \(!normalizedAbsolutePath \|\| !normalizedName\) \{\s*return;\s*\}/,
655654
);
656655
assert.match(source, /setActiveShellView\("space"\);/);
657656
assert.match(source, /setSpaceVisibility\(\(previous\) => \(\{\s*\.\.\.previous,\s*agent: true,\s*\}\)\);/);
658657
assert.match(source, /setAgentView\(\{ type: "chat" \}\);/);
659658
assert.match(
660659
source,
661-
/if \(entry\.isDirectory\) \{\s*setChatComposerPrefillRequest\(\{\s*text: normalizedReferenceText,\s*requestKey: nextChatComposerPrefillRequestKey\(\),\s*mode: "append",\s*\}\);\s*\} else \{\s*setChatExplorerAttachmentRequest\(\{\s*files: \[\s*\{\s*absolutePath: normalizedAbsolutePath,\s*name: normalizedName,\s*size: Number\.isFinite\(entry\.size\) \? Math\.max\(0, entry\.size\) : 0,\s*\},\s*\],\s*requestKey: nextChatExplorerAttachmentRequestKey\(\),\s*\}\);\s*\}/,
660+
/setChatExplorerAttachmentRequest\(\{\s*files: \[\s*\{\s*absolutePath: normalizedAbsolutePath,\s*name: normalizedName,\s*size: Number\.isFinite\(entry\.size\) \? Math\.max\(0, entry\.size\) : 0,\s*mimeType: entry\.isDirectory \? "inode\/directory" : null,\s*kind: entry\.isDirectory \? "folder" : undefined,\s*\},\s*\],\s*requestKey: nextChatExplorerAttachmentRequestKey\(\),\s*\}\);/,
662661
);
663662
assert.match(source, /setChatFocusRequestKey\(\(current\) => current \+ 1\);/);
664663
assert.match(
@@ -672,6 +671,23 @@ test("app shell can route explorer references into chat attachments or text pref
672671
assert.match(source, /<FileExplorerPane[\s\S]*onReferenceInChat=\{handleReferenceWorkspacePathInChat\}/);
673672
});
674673

674+
test("app shell clears missing internal file surfaces after explorer deletion or preview invalidation", async () => {
675+
const source = await readFile(APP_SHELL_PATH, "utf8");
676+
677+
assert.match(source, /function normalizeComparablePath\(targetPath: string\)/);
678+
assert.match(source, /function isPathWithin\(parentPath: string, targetPath: string\)/);
679+
assert.match(
680+
source,
681+
/const handleMissingInternalResource = useCallback\(\s*\(resourceId: string\) => \{[\s\S]*setAgentView\(\(current\) => \{[\s\S]*return \{ type: "chat" \};[\s\S]*\}\);[\s\S]*setSpaceDisplayView\(\(current\) => \{[\s\S]*delete lastRestorableSpaceFileDisplayViewByWorkspaceRef\.current\[[\s\S]*selectedWorkspaceId[\s\S]*\];[\s\S]*return \{ type: "empty" \};[\s\S]*\}\);/,
682+
);
683+
assert.match(
684+
source,
685+
/const handleDeleteWorkspaceEntry = useCallback\(\s*\(entry: LocalFileEntry\) => \{[\s\S]*const normalizedDeletedPath = normalizeComparablePath\(entry\.absolutePath\);[\s\S]*setSpaceDisplayView\(\(current\) => \{[\s\S]*if \(!isPathWithin\(normalizedDeletedPath, current\.resourceId\?\.trim\(\) \?\? ""\)\) \{\s*return current;\s*\}[\s\S]*return \{ type: "empty" \};[\s\S]*\}\);/,
686+
);
687+
assert.match(source, /<InternalSurfacePane[\s\S]*onResourceMissing=\{handleMissingInternalResource\}/);
688+
assert.match(source, /<FileExplorerPane[\s\S]*onDeleteEntry=\{handleDeleteWorkspaceEntry\}/);
689+
});
690+
675691
test("app shell passes new session requests into the chat pane selector", async () => {
676692
const source = await readFile(APP_SHELL_PATH, "utf8");
677693

0 commit comments

Comments
 (0)