Skip to content

Commit 1d07265

Browse files
authored
Merge pull request #62 from spencerkit/develop
Merge develop into main for patch release
2 parents 8a883e0 + e95251e commit 1d07265

49 files changed

Lines changed: 8311 additions & 157 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.changeset/few-turtles-teach.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@spencer-kit/coder-studio": patch
3+
---
4+
5+
Improve desktop workspace ergonomics by adding keyboard pane navigation,
6+
supporting workspace path drops into terminal sessions, and launching themed
7+
PTYs with terminal-aware background environment hints.

docs/superpowers/plans/2026-05-24-desktop-file-tree-drag-to-terminal.md

Lines changed: 186 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
55
**Goal:** Allow desktop users to drag file-tree rows into the active terminal to insert shell-quoted workspace-relative paths without uploading anything.
66

7-
**Architecture:** Define one shared drag payload contract in `packages/web/src/lib`, then teach the terminal drop hook to recognize that payload before the existing file-upload path, and finally make desktop `FileTreeNode` rows emit the payload on `dragstart`. Keep the change web-only, leave search/mobile/open-editors untouched, and reuse the existing `quoteShellSingle()` plus `sendTextToTerminal()` path so terminal input stays consistent.
7+
**Architecture:** Define one shared drag payload contract in `packages/web/src/lib`, then teach the terminal drop hook to recognize that payload before the existing file-upload path, and finally make desktop `FileTreeNode` rows emit the payload on `dragstart`. Keep the change web-only, leave search/mobile/open-editors untouched, and reuse the existing `quoteShellSingle()` plus terminal insertion sequencing so internal path drops stay ordered relative to uploads without incorrectly entering the upload-busy state.
88

99
**Tech Stack:** TypeScript, React 19, Vitest, Testing Library, Jotai, DOM Drag and Drop APIs
1010

@@ -303,6 +303,57 @@ it("prevents default for internal workspace drags and inserts a quoted relative
303303
expect(result.current.busy).toBe(false);
304304
});
305305

306+
it("keeps internal path insertion ordered behind earlier uploads", async () => {
307+
const store = createStore();
308+
let resolveUpload: ((value: Response) => void) | undefined;
309+
vi.stubGlobal(
310+
"fetch",
311+
vi.fn().mockImplementation(
312+
() =>
313+
new Promise((resolve) => {
314+
resolveUpload = resolve as (value: Response) => void;
315+
})
316+
)
317+
);
318+
319+
renderHook(
320+
() =>
321+
usePasteDropUpload({
322+
containerRef: { current: container },
323+
workspaceId: "ws-1",
324+
sendTextToTerminal: sendInput,
325+
enabled: true,
326+
}),
327+
{ wrapper: makeWrapper(store) }
328+
);
329+
330+
await act(async () => {
331+
fireDrop(container, [makeFile("upload.txt")]);
332+
fireWorkspacePathDrop(container, {
333+
workspaceId: "ws-1",
334+
path: "src/app.tsx",
335+
kind: "file",
336+
});
337+
await Promise.resolve();
338+
});
339+
340+
expect(sendInput).not.toHaveBeenCalled();
341+
342+
await act(async () => {
343+
resolveUpload?.({
344+
ok: true,
345+
status: 200,
346+
json: async () => ({
347+
ok: true,
348+
files: [{ path: "/abs/upload.txt", originalName: "upload.txt", size: 1 }],
349+
}),
350+
} as Response);
351+
await flushAsyncWork();
352+
});
353+
354+
expect(sendInput.mock.calls).toEqual([["'/abs/upload.txt' "], ["'src/app.tsx' "]]);
355+
});
356+
306357
it("rejects internal workspace drops from another workspace", async () => {
307358
const store = createStore();
308359
const { result } = renderHook(
@@ -334,6 +385,45 @@ it("rejects internal workspace drops from another workspace", async () => {
334385
);
335386
expect(result.current.busy).toBe(false);
336387
});
388+
389+
it("toasts when the internal workspace payload is invalid", async () => {
390+
const store = createStore();
391+
392+
renderHook(
393+
() =>
394+
usePasteDropUpload({
395+
containerRef: { current: container },
396+
workspaceId: "ws-1",
397+
sendTextToTerminal: sendInput,
398+
enabled: true,
399+
}),
400+
{ wrapper: makeWrapper(store) }
401+
);
402+
403+
await act(async () => {
404+
const event = new Event("drop", { bubbles: true, cancelable: true });
405+
Object.defineProperty(event, "dataTransfer", {
406+
value: {
407+
files: [],
408+
types: [WORKSPACE_PATH_DRAG_MIME, "text/plain"],
409+
items: [],
410+
getData: (type: string) =>
411+
type === WORKSPACE_PATH_DRAG_MIME ? "{bad json" : "src/app.tsx",
412+
},
413+
});
414+
container.dispatchEvent(event);
415+
await flushAsyncWork();
416+
});
417+
418+
expect(sendInput).not.toHaveBeenCalled();
419+
expect(store.get(toastsAtom)).toContainEqual(
420+
expect.objectContaining({
421+
kind: "error",
422+
title: "Drop failed",
423+
body: "Could not read the dragged workspace path.",
424+
})
425+
);
426+
});
337427
```
338428

339429
- [ ] **Step 2: Run the terminal drop-hook tests to verify they fail**
@@ -346,7 +436,7 @@ pnpm --filter @coder-studio/web exec vitest run src/features/terminal-panel/uplo
346436

347437
Expected: FAIL on the new workspace-path drag tests because the hook still ignores the custom MIME payload, so `defaultPrevented` stays `false` and `sendTextToTerminal()` is never called.
348438

349-
- [ ] **Step 3: Implement internal workspace-path drop parsing without touching upload flow**
439+
- [ ] **Step 3: Implement internal workspace-path drop parsing while preserving terminal insertion order**
350440

351441
Update the imports and helpers in `packages/web/src/features/terminal-panel/uploads/use-paste-drop-upload.ts`:
352442

@@ -357,6 +447,53 @@ import {
357447
} from "../../../lib/workspace-path-drag";
358448
```
359449

450+
Before wiring the new callback, refactor `runSequence()` so uploads and internal path drops share the same output queue while only uploads toggle `busy` and use the default upload toast:
451+
452+
```ts
453+
interface RunSequenceOptions {
454+
trackBusy?: boolean;
455+
onError?: (error: unknown) => void;
456+
}
457+
458+
const runSequence = useCallback(
459+
async (task: () => Promise<string | null>, options?: RunSequenceOptions) => {
460+
const { trackBusy = true, onError } = options ?? {};
461+
const sequence = nextSequenceRef.current;
462+
nextSequenceRef.current += 1;
463+
464+
if (trackBusy) {
465+
inFlightCountRef.current += 1;
466+
setBusy(true);
467+
}
468+
469+
try {
470+
const text = await task();
471+
await settleSequence(sequence, text);
472+
} catch (error) {
473+
await settleSequence(sequence, null);
474+
if (onError) {
475+
onError(error);
476+
return;
477+
}
478+
479+
const code = error instanceof UploadError ? error.code : "unknown";
480+
pushToast({
481+
kind: "error",
482+
title: "Upload failed",
483+
body: `Could not upload file(s): ${code}`,
484+
duration: 5_000,
485+
});
486+
} finally {
487+
if (trackBusy) {
488+
inFlightCountRef.current = Math.max(0, inFlightCountRef.current - 1);
489+
setBusy(inFlightCountRef.current > 0);
490+
}
491+
}
492+
},
493+
[pushToast, settleSequence]
494+
);
495+
```
496+
360497
Add this callback next to the existing `handleText()` callback:
361498

362499
```ts
@@ -383,19 +520,20 @@ Add this callback next to the existing `handleText()` callback:
383520
return;
384521
}
385522

386-
try {
387-
await sendTextToTerminal(`${quoteShellSingle(payload.path)} `);
388-
} catch (error) {
389-
console.debug("Workspace path drop failed:", error);
390-
pushToast({
391-
kind: "error",
392-
title: "Drop failed",
393-
body: "Could not insert the dragged path into the terminal.",
394-
duration: 3_000,
395-
});
396-
}
523+
await runSequence(async () => `${quoteShellSingle(payload.path)} `, {
524+
trackBusy: false,
525+
onError: (error) => {
526+
console.debug("Workspace path drop failed:", error);
527+
pushToast({
528+
kind: "error",
529+
title: "Drop failed",
530+
body: "Could not insert the dragged path into the terminal.",
531+
duration: 3_000,
532+
});
533+
},
534+
});
397535
},
398-
[pushToast, sendTextToTerminal, workspaceId]
536+
[pushToast, runSequence, workspaceId]
399537
);
400538
```
401539

@@ -529,6 +667,40 @@ it("marks desktop tree rows draggable and writes workspace path drag data on dra
529667
expect(values.get("text/plain")).toBe("README.md");
530668
});
531669

670+
it("writes workspace drag data for nested desktop nodes too", () => {
671+
const store = createStore();
672+
store.set(wsClientAtom, { sendCommand: vi.fn() } as never);
673+
store.set(
674+
fileTreeAtomFamily("ws-test"),
675+
new Map([
676+
[".", [{ path: "src", name: "src", kind: "dir", children: [] }]],
677+
["src", [{ path: "src/app.tsx", name: "app.tsx", kind: "file" }]],
678+
])
679+
);
680+
store.set(expandedDirsAtomFamily("ws-test"), new Set(["src"]));
681+
682+
render(
683+
<Provider store={store}>
684+
<FileTreePanel workspaceId="ws-test" variant="desktop" showSearch={false} />
685+
</Provider>
686+
);
687+
688+
const nestedRow = screen.getByText("app.tsx").closest(".tree-item");
689+
expect(nestedRow).toHaveAttribute("draggable", "true");
690+
691+
const { dataTransfer, values } = createDragDataTransfer();
692+
fireEvent.dragStart(nestedRow!, { dataTransfer });
693+
694+
expect(values.get(WORKSPACE_PATH_DRAG_MIME)).toBe(
695+
JSON.stringify({
696+
workspaceId: "ws-test",
697+
path: "src/app.tsx",
698+
kind: "file",
699+
})
700+
);
701+
expect(values.get("text/plain")).toBe("src/app.tsx");
702+
});
703+
532704
it("keeps mobile tree rows non-draggable", () => {
533705
const store = createStore();
534706
store.set(wsClientAtom, { sendCommand: vi.fn() } as never);

0 commit comments

Comments
 (0)