-
-
Notifications
You must be signed in to change notification settings - Fork 12
Shell: cross-app drag-drop primitive + Files → Messages wiring #239
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -28,6 +28,7 @@ import { Button, Card, Toolbar, ToolbarGroup, ToolbarSpacer } from "@/components | |
| import { MobileSplitView } from "@/components/mobile/MobileSplitView"; | ||
| import { useIsMobile } from "@/hooks/use-is-mobile"; | ||
| import { resolveAgentEmoji } from "@/lib/agent-emoji"; | ||
| import { useDragSource } from "@/shell/dnd/use-drag-source"; | ||
|
|
||
| /* ------------------------------------------------------------------ */ | ||
| /* Types */ | ||
|
|
@@ -227,6 +228,138 @@ async function apiFetch<T>(url: string, opts?: RequestInit): Promise<T> { | |
| return res.json(); | ||
| } | ||
|
|
||
| /* ------------------------------------------------------------------ */ | ||
| /* FileRow — list-view row with drag source */ | ||
| /* ------------------------------------------------------------------ */ | ||
|
|
||
| interface FileRowProps { | ||
| f: FileEntry; | ||
| location: "workspace" | string; | ||
| currentPath: string; | ||
| navigateTo: (path: string) => void; | ||
| isWritable: boolean; | ||
| deleteConfirm: string | null; | ||
| handleDelete: (path: string) => void; | ||
| setDeleteConfirm: (path: string | null) => void; | ||
| } | ||
|
|
||
| function FileRow({ | ||
| f, | ||
| location, | ||
| currentPath, | ||
| navigateTo, | ||
| isWritable, | ||
| deleteConfirm, | ||
| handleDelete, | ||
| setDeleteConfirm, | ||
| }: FileRowProps) { | ||
| const Icon = getFileIcon(f.name, f.is_dir); | ||
| const relPath = f.path || (currentPath ? `${currentPath}/${f.name}` : f.name); | ||
|
|
||
| let vfsPath: string | null = null; | ||
| if (location === "workspace") { | ||
| vfsPath = `/workspaces/user/${relPath}`; | ||
| } else if (isAgentLocation(location)) { | ||
| vfsPath = `/workspaces/agent/${agentSlug(location)}/${relPath}`; | ||
| } | ||
|
|
||
| const dragEnabled = !!vfsPath && !f.is_dir; | ||
| const { dragHandlers } = useDragSource({ | ||
| // When dragEnabled is false, `disabled: true` short-circuits the payload | ||
| // before it ever lands on the bus — the empty-string placeholder below | ||
| // is never read. | ||
| payload: { | ||
| kind: "file", | ||
| path: vfsPath ?? "", | ||
| mime_type: "application/octet-stream", | ||
| size: f.size ?? 0, | ||
| name: f.name, | ||
| }, | ||
| disabled: !dragEnabled, | ||
| htmlMirror: dragEnabled && vfsPath ? { "text/plain": vfsPath } : undefined, | ||
| }); | ||
|
Comment on lines
+246
to
+280
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Wire the drag source into grid view too.
Also applies to: 1362-1374 🤖 Prompt for AI Agents |
||
|
|
||
| return ( | ||
| <tr | ||
| key={f.path || f.name} | ||
| data-file-row | ||
| className="border-b border-white/5 hover:bg-shell-surface/50 transition-colors group" | ||
| {...dragHandlers} | ||
| > | ||
| <td className="px-3 py-2"> | ||
| <button | ||
| onClick={() => { | ||
| if (f.is_dir) { | ||
| navigateTo(f.path || (currentPath ? `${currentPath}/${f.name}` : f.name)); | ||
| } | ||
| }} | ||
| className="flex items-center gap-2 min-w-0" | ||
| aria-label={f.is_dir ? `Open folder ${f.name}` : `File ${f.name}`} | ||
| > | ||
| {!f.is_dir && isImage(f.name) ? ( | ||
| <img | ||
| src={fileUrl(location, f.path || f.name)} | ||
| alt="" | ||
| loading="lazy" | ||
| decoding="async" | ||
| className="w-6 h-6 rounded object-cover border border-white/[0.06] shrink-0" | ||
| onError={(e) => { | ||
| e.currentTarget.style.display = "none"; | ||
| }} | ||
| /> | ||
| ) : ( | ||
| <Icon size={16} className={f.is_dir ? "text-accent shrink-0" : "text-shell-text-secondary shrink-0"} /> | ||
| )} | ||
| <span className="truncate">{f.name}</span> | ||
| </button> | ||
| </td> | ||
| <td className="px-3 py-2 text-shell-text-tertiary"> | ||
| {f.is_dir ? "—" : formatSize(f.size)} | ||
| </td> | ||
| <td className="px-3 py-2 text-shell-text-tertiary"> | ||
| {formatDate(f.modified)} | ||
| </td> | ||
| <td className="px-3 py-2"> | ||
| <div className="flex items-center gap-1"> | ||
| {!f.is_dir && isWritable && ( | ||
| <a | ||
| href={fileUrl(location, f.path || f.name)} | ||
| target="_blank" | ||
| rel="noopener noreferrer" | ||
| className="p-1 rounded-md opacity-0 group-hover:opacity-100 hover:bg-shell-surface transition-all text-shell-text-tertiary hover:text-shell-text" | ||
| aria-label={`Download ${f.name}`} | ||
| > | ||
| <Download size={13} /> | ||
| </a> | ||
| )} | ||
| {isWritable && ( | ||
| <Button | ||
| variant="ghost" | ||
| size="icon" | ||
| onClick={() => { | ||
| if (deleteConfirm === f.path) { | ||
| handleDelete(f.path); | ||
| } else { | ||
| setDeleteConfirm(f.path); | ||
| } | ||
| }} | ||
| className={`h-7 w-7 transition-all ${ | ||
| deleteConfirm === f.path | ||
| ? "bg-red-500/20 text-red-400 opacity-100 hover:bg-red-500/25 hover:text-red-400" | ||
| : "opacity-0 group-hover:opacity-100 hover:bg-red-500/20 hover:text-red-400" | ||
| }`} | ||
| aria-label={deleteConfirm === f.path ? `Confirm delete ${f.name}` : `Delete ${f.name}`} | ||
| title={deleteConfirm === f.path ? "Click again to confirm" : "Delete"} | ||
| > | ||
| <Trash2 size={13} /> | ||
| </Button> | ||
| )} | ||
| </div> | ||
| </td> | ||
| </tr> | ||
| ); | ||
| } | ||
|
|
||
| /* ------------------------------------------------------------------ */ | ||
| /* Component */ | ||
| /* ------------------------------------------------------------------ */ | ||
|
|
@@ -1226,86 +1359,19 @@ export function FilesApp({ windowId: _windowId }: { windowId: string }) { | |
| </tr> | ||
| </thead> | ||
| <tbody> | ||
| {sortedFiles.map((f) => { | ||
| const Icon = getFileIcon(f.name, f.is_dir); | ||
| return ( | ||
| <tr | ||
| key={f.path || f.name} | ||
| className="border-b border-white/5 hover:bg-shell-surface/50 transition-colors group" | ||
| > | ||
| <td className="px-3 py-2"> | ||
| <button | ||
| onClick={() => { | ||
| if (f.is_dir) { | ||
| navigateTo(f.path || (currentPath ? `${currentPath}/${f.name}` : f.name)); | ||
| } | ||
| }} | ||
| className="flex items-center gap-2 min-w-0" | ||
| aria-label={f.is_dir ? `Open folder ${f.name}` : `File ${f.name}`} | ||
| > | ||
| {!f.is_dir && isImage(f.name) ? ( | ||
| <img | ||
| src={fileUrl(location, f.path || f.name)} | ||
| alt="" | ||
| loading="lazy" | ||
| decoding="async" | ||
| className="w-6 h-6 rounded object-cover border border-white/[0.06] shrink-0" | ||
| onError={(e) => { | ||
| e.currentTarget.style.display = "none"; | ||
| }} | ||
| /> | ||
| ) : ( | ||
| <Icon size={16} className={f.is_dir ? "text-accent shrink-0" : "text-shell-text-secondary shrink-0"} /> | ||
| )} | ||
| <span className="truncate">{f.name}</span> | ||
| </button> | ||
| </td> | ||
| <td className="px-3 py-2 text-shell-text-tertiary"> | ||
| {f.is_dir ? "—" : formatSize(f.size)} | ||
| </td> | ||
| <td className="px-3 py-2 text-shell-text-tertiary"> | ||
| {formatDate(f.modified)} | ||
| </td> | ||
| <td className="px-3 py-2"> | ||
| <div className="flex items-center gap-1"> | ||
| {!f.is_dir && isWritable && ( | ||
| <a | ||
| href={fileUrl(location, f.path || f.name)} | ||
| target="_blank" | ||
| rel="noopener noreferrer" | ||
| className="p-1 rounded-md opacity-0 group-hover:opacity-100 hover:bg-shell-surface transition-all text-shell-text-tertiary hover:text-shell-text" | ||
| aria-label={`Download ${f.name}`} | ||
| > | ||
| <Download size={13} /> | ||
| </a> | ||
| )} | ||
| {isWritable && ( | ||
| <Button | ||
| variant="ghost" | ||
| size="icon" | ||
| onClick={() => { | ||
| if (deleteConfirm === f.path) { | ||
| handleDelete(f.path); | ||
| } else { | ||
| setDeleteConfirm(f.path); | ||
| } | ||
| }} | ||
| className={`h-7 w-7 transition-all ${ | ||
| deleteConfirm === f.path | ||
| ? "bg-red-500/20 text-red-400 opacity-100 hover:bg-red-500/25 hover:text-red-400" | ||
| : "opacity-0 group-hover:opacity-100 hover:bg-red-500/20 hover:text-red-400" | ||
| }`} | ||
| aria-label={deleteConfirm === f.path ? `Confirm delete ${f.name}` : `Delete ${f.name}`} | ||
| title={deleteConfirm === f.path ? "Click again to confirm" : "Delete"} | ||
| > | ||
| <Trash2 size={13} /> | ||
| </Button> | ||
| )} | ||
| </div> | ||
| </td> | ||
| </tr> | ||
| ); | ||
| })} | ||
| {sortedFiles.map((f) => ( | ||
| <FileRow | ||
| key={f.path || f.name} | ||
| f={f} | ||
| location={location} | ||
| currentPath={currentPath} | ||
| navigateTo={navigateTo} | ||
| isWritable={isWritable} | ||
| deleteConfirm={deleteConfirm} | ||
| handleDelete={handleDelete} | ||
| setDeleteConfirm={setDeleteConfirm} | ||
| /> | ||
| ))} | ||
| </tbody> | ||
| </table> | ||
| </div> | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,55 @@ | ||
| import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; | ||
| import { startDrag, endDrag, getCurrent, subscribe } from "../dnd-bus"; | ||
|
|
||
| const samplePayload = { kind: "file" as const, path: "/a/b.png", mime_type: "image/png", size: 10, name: "b.png" }; | ||
|
|
||
| describe("dnd-bus", () => { | ||
| beforeEach(() => { | ||
| endDrag(); | ||
| vi.useFakeTimers(); | ||
| }); | ||
| afterEach(() => { | ||
| vi.useRealTimers(); | ||
| endDrag(); | ||
| }); | ||
|
|
||
| it("startDrag sets current and notifies subscribers", () => { | ||
| const fn = vi.fn(); | ||
| const unsub = subscribe(fn); | ||
| startDrag(samplePayload); | ||
| expect(getCurrent()).toEqual(samplePayload); | ||
| expect(fn).toHaveBeenCalled(); | ||
| unsub(); | ||
| }); | ||
|
|
||
| it("endDrag clears current", () => { | ||
| startDrag(samplePayload); | ||
| endDrag(); | ||
| expect(getCurrent()).toBeNull(); | ||
| }); | ||
|
|
||
| it("30s stale timeout auto-clears", () => { | ||
| startDrag(samplePayload); | ||
| expect(getCurrent()).not.toBeNull(); | ||
| vi.advanceTimersByTime(30_000); | ||
| expect(getCurrent()).toBeNull(); | ||
| }); | ||
|
|
||
| it("starting a new drag resets the stale timer", () => { | ||
| startDrag(samplePayload); | ||
| vi.advanceTimersByTime(25_000); | ||
| startDrag({ ...samplePayload, name: "c.png" }); | ||
| vi.advanceTimersByTime(15_000); | ||
| expect(getCurrent()).not.toBeNull(); | ||
| vi.advanceTimersByTime(20_000); | ||
| expect(getCurrent()).toBeNull(); | ||
| }); | ||
|
|
||
| it("subscribers receive change events for both start and end", () => { | ||
| const fn = vi.fn(); | ||
| subscribe(fn); | ||
| startDrag(samplePayload); | ||
| endDrag(); | ||
| expect(fn).toHaveBeenCalledTimes(2); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
WARNING: When
vfsPathis null, this passes an empty string aspathin the drag payload (due to?? ""). Empty paths will fail validation in drop targets.The payload should never have an empty path - ensure
vfsPathis always valid when dragEnabled is true.