@houston-ai/workspace 0.4.2

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/package.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "@houston-ai/workspace",
3
+ "version": "0.4.2",
4
+ "type": "module",
5
+ "main": "src/index.ts",
6
+ "types": "src/index.ts",
7
+ "files": [
8
+ "src"
9
+ ],
10
+ "peerDependencies": {
11
+ "react": "^19.0.0",
12
+ "react-dom": "^19.0.0",
13
+ "@houston-ai/core": "workspace:*"
14
+ },
15
+ "dependencies": {
16
+ "lucide-react": "^0.577.0"
17
+ },
18
+ "scripts": {
19
+ "typecheck": "tsc --noEmit"
20
+ }
21
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Drop zone hooks for drag-and-drop.
3
+ * Container handles ALL drops. Folders only provide visual highlight state.
4
+ */
5
+ import { useCallback, useRef, useState } from "react"
6
+
7
+ /** MIME type used for internal file moves. */
8
+ export const INTERNAL_DRAG_TYPE = "application/x-deck-file"
9
+
10
+ function hasDragData(e: React.DragEvent) {
11
+ return e.dataTransfer.types.includes("Files") || e.dataTransfer.types.includes(INTERNAL_DRAG_TYPE)
12
+ }
13
+
14
+ /** Container-level drop zone. Handles ALL drop events (both external and internal). */
15
+ export function useDropZone(
16
+ onFilesDropped?: (files: File[], targetFolder?: string) => void,
17
+ onMove?: (sourcePath: string, targetFolder: string | null) => void,
18
+ ) {
19
+ const [isDragging, setIsDragging] = useState(false)
20
+ const counter = useRef(0)
21
+
22
+ const onDragEnter = useCallback((e: React.DragEvent) => {
23
+ e.preventDefault()
24
+ counter.current++
25
+ if (hasDragData(e)) setIsDragging(true)
26
+ }, [])
27
+
28
+ const onDragLeave = useCallback((e: React.DragEvent) => {
29
+ e.preventDefault()
30
+ counter.current--
31
+ if (counter.current === 0) setIsDragging(false)
32
+ }, [])
33
+
34
+ const onDragOver = useCallback((e: React.DragEvent) => {
35
+ e.preventDefault()
36
+ }, [])
37
+
38
+ const onDrop = useCallback(
39
+ (e: React.DragEvent) => {
40
+ e.preventDefault()
41
+ counter.current = 0
42
+ setIsDragging(false)
43
+ const internal = e.dataTransfer.getData(INTERNAL_DRAG_TYPE)
44
+ if (internal && onMove) { onMove(internal, null); return }
45
+ if (!onFilesDropped) return
46
+ const files = Array.from(e.dataTransfer.files)
47
+ if (files.length > 0) onFilesDropped(files)
48
+ },
49
+ [onFilesDropped, onMove],
50
+ )
51
+
52
+ return {
53
+ isDragging,
54
+ dragHandlers: { onDragEnter, onDragLeave, onDragOver, onDrop },
55
+ }
56
+ }
57
+
58
+ /** Folder-level highlight only — does NOT handle the drop (container does). */
59
+ export function useFolderDropTarget() {
60
+ const [isOver, setIsOver] = useState(false)
61
+ const counter = useRef(0)
62
+
63
+ const onDragEnter = useCallback((e: React.DragEvent) => {
64
+ e.preventDefault()
65
+ counter.current++
66
+ if (hasDragData(e)) setIsOver(true)
67
+ }, [])
68
+
69
+ const onDragLeave = useCallback((e: React.DragEvent) => {
70
+ e.preventDefault()
71
+ counter.current--
72
+ if (counter.current === 0) setIsOver(false)
73
+ }, [])
74
+
75
+ const onDragOver = useCallback((e: React.DragEvent) => {
76
+ e.preventDefault()
77
+ }, [])
78
+
79
+ const onDrop = useCallback((e: React.DragEvent) => {
80
+ e.preventDefault()
81
+ // Do NOT stopPropagation — let container handle the actual drop
82
+ counter.current = 0
83
+ setIsOver(false)
84
+ }, [])
85
+
86
+ return {
87
+ isOver,
88
+ folderHandlers: { onDragEnter, onDragLeave, onDragOver, onDrop },
89
+ }
90
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Lightweight right-click context menu for file rows.
3
+ * Portal-based — renders at cursor position, closes on outside click or Escape.
4
+ */
5
+ import type { ReactNode } from "react"
6
+ import { createPortal } from "react-dom"
7
+ import { ExternalLink, FolderSearch, Trash2 } from "lucide-react"
8
+ import type { FileEntry } from "./types"
9
+
10
+ export function FileMenu({
11
+ file,
12
+ position,
13
+ onClose,
14
+ onOpen,
15
+ onReveal,
16
+ onDelete,
17
+ }: {
18
+ file: FileEntry
19
+ position: { x: number; y: number }
20
+ onClose: () => void
21
+ onOpen?: (file: FileEntry) => void
22
+ onReveal?: (file: FileEntry) => void
23
+ onDelete?: (file: FileEntry) => void
24
+ }) {
25
+ return createPortal(
26
+ <>
27
+ <div
28
+ className="fixed inset-0 z-40"
29
+ onClick={onClose}
30
+ onContextMenu={(e) => { e.preventDefault(); onClose() }}
31
+ />
32
+ <div
33
+ className="fixed z-50 min-w-[160px] rounded-md border bg-popover p-1 text-popover-foreground shadow-md"
34
+ style={{ left: position.x, top: position.y }}
35
+ onKeyDown={(e) => e.key === "Escape" && onClose()}
36
+ >
37
+ {onOpen && (
38
+ <MenuItem onClick={() => { onOpen(file); onClose() }} icon={<ExternalLink />} label="Open" />
39
+ )}
40
+ {onReveal && (
41
+ <MenuItem onClick={() => { onReveal(file); onClose() }} icon={<FolderSearch />} label="Show in Finder" />
42
+ )}
43
+ {(onOpen || onReveal) && onDelete && <div className="-mx-1 my-1 h-px bg-border" />}
44
+ {onDelete && (
45
+ <MenuItem onClick={() => { onDelete(file); onClose() }} icon={<Trash2 />} label="Move to Trash" destructive />
46
+ )}
47
+ </div>
48
+ </>,
49
+ document.body,
50
+ )
51
+ }
52
+
53
+ function MenuItem({ onClick, icon, label, destructive }: {
54
+ onClick: () => void
55
+ icon: ReactNode
56
+ label: string
57
+ destructive?: boolean
58
+ }) {
59
+ return (
60
+ <button
61
+ onClick={onClick}
62
+ className={`flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none hover:bg-accent [&_svg]:size-4 [&_svg]:shrink-0 [&_svg]:text-muted-foreground ${destructive ? "text-destructive [&_svg]:text-destructive" : ""}`}
63
+ >
64
+ {icon}
65
+ {label}
66
+ </button>
67
+ )
68
+ }
@@ -0,0 +1,198 @@
1
+ /**
2
+ * Finder-style file and folder rows.
3
+ * Files: click to select, double-click to open, right-click context menu, draggable.
4
+ * Folders: click to expand/collapse, drop target for moves.
5
+ */
6
+ import { useEffect, useRef, useState } from "react"
7
+ import { cn } from "@houston-ai/core"
8
+ import type { FileEntry } from "./types"
9
+ import type { FolderNode } from "./tree"
10
+ import { INTERNAL_DRAG_TYPE, useFolderDropTarget } from "./drop-zone"
11
+ import { FolderIcon, DisclosureChevron, getFileIcon } from "./finder-icons"
12
+ import { formatSize, formatFinderDate, getKind } from "./utils"
13
+ import { FileMenu } from "./file-menu"
14
+
15
+ const DEPTH_INDENT = 20
16
+ const BASE_INDENT = 12
17
+ const TRIANGLE_AREA = 16
18
+
19
+ /** Column grid shared between header and rows. */
20
+ export const COL_GRID = "1fr 190px 80px 130px"
21
+
22
+ export function FolderSection({
23
+ node, depth, selectedPath, onSelect, onOpen, onReveal, onDelete,
24
+ onRename, onFilesDropped, onDragActive, onMove,
25
+ }: {
26
+ node: FolderNode
27
+ depth: number
28
+ selectedPath?: string | null
29
+ onSelect?: (file: FileEntry) => void
30
+ onOpen?: (file: FileEntry) => void
31
+ onReveal?: (file: FileEntry) => void
32
+ onDelete?: (file: FileEntry) => void
33
+ onRename?: (file: FileEntry, newName: string) => void
34
+ onFilesDropped?: (files: File[], targetFolder?: string) => void
35
+ onDragActive?: (folder: string | null) => void
36
+ onMove?: (sourcePath: string, targetFolder: string | null) => void
37
+ }) {
38
+ const [open, setOpen] = useState(true)
39
+ const [dragging, setDragging] = useState(false)
40
+ const { isOver, folderHandlers } = useFolderDropTarget()
41
+
42
+ useEffect(() => {
43
+ onDragActive?.(isOver ? node.path : null)
44
+ }, [isOver, node.path, onDragActive])
45
+
46
+ const padLeft = BASE_INDENT + depth * DEPTH_INDENT
47
+
48
+ return (
49
+ <>
50
+ <div
51
+ role="button"
52
+ tabIndex={0}
53
+ draggable={!!onMove}
54
+ onDragStart={(e) => { e.dataTransfer.setData(INTERNAL_DRAG_TYPE, node.path); e.dataTransfer.effectAllowed = "move"; setDragging(true) }}
55
+ onDragEnd={() => setDragging(false)}
56
+ onClick={() => setOpen(!open)}
57
+ onKeyDown={(e) => e.key === "Enter" && setOpen(!open)}
58
+ className={cn(
59
+ "h-[24px] select-none cursor-default items-center",
60
+ isOver ? "!bg-[rgba(0,122,255,0.12)] !rounded-lg" : "",
61
+ dragging && "opacity-40",
62
+ )}
63
+ style={{ display: "grid", gridTemplateColumns: COL_GRID }}
64
+ {...folderHandlers}
65
+ >
66
+ <div className="flex items-center gap-1.5 min-w-0" style={{ paddingLeft: padLeft }}>
67
+ <DisclosureChevron open={open} />
68
+ <FolderIcon />
69
+ <span className="text-[13px] truncate">{node.name}</span>
70
+ </div>
71
+ <span className="text-[11px] text-[#6d6d6d] truncate px-2">{"\u2014"}</span>
72
+ <span className="text-[11px] text-[#6d6d6d] text-right px-2">--</span>
73
+ <span className="text-[11px] text-[#6d6d6d] truncate px-2">Folder</span>
74
+ </div>
75
+ {open &&
76
+ node.children.map((child) =>
77
+ child.kind === "folder" ? (
78
+ <FolderSection
79
+ key={child.path} node={child} depth={depth + 1}
80
+ selectedPath={selectedPath} onSelect={onSelect}
81
+ onOpen={onOpen} onReveal={onReveal} onDelete={onDelete}
82
+ onRename={onRename}
83
+ onFilesDropped={onFilesDropped} onDragActive={onDragActive} onMove={onMove}
84
+ />
85
+ ) : (
86
+ <FileRow
87
+ key={child.entry.path} file={child.entry} depth={depth + 1}
88
+ selected={selectedPath === child.entry.path}
89
+ onSelect={onSelect} onOpen={onOpen}
90
+ onReveal={onReveal} onDelete={onDelete} onRename={onRename} onMove={onMove}
91
+ />
92
+ ),
93
+ )}
94
+ </>
95
+ )
96
+ }
97
+
98
+ export function FileRow({
99
+ file, depth = 0, selected, onSelect, onOpen, onReveal, onDelete, onRename, onMove,
100
+ }: {
101
+ file: FileEntry
102
+ depth?: number
103
+ selected?: boolean
104
+ onSelect?: (file: FileEntry) => void
105
+ onOpen?: (file: FileEntry) => void
106
+ onReveal?: (file: FileEntry) => void
107
+ onDelete?: (file: FileEntry) => void
108
+ onRename?: (file: FileEntry, newName: string) => void
109
+ onMove?: (sourcePath: string, targetFolder: string | null) => void
110
+ }) {
111
+ const [menu, setMenu] = useState<{ x: number; y: number } | null>(null)
112
+ const [dragging, setDragging] = useState(false)
113
+ const [renaming, setRenaming] = useState(false)
114
+ const [renameValue, setRenameValue] = useState("")
115
+ const renameRef = useRef<HTMLInputElement>(null)
116
+ const padLeft = BASE_INDENT + depth * DEPTH_INDENT + TRIANGLE_AREA
117
+ const hasMenu = onOpen || onReveal || onDelete
118
+ const sec = selected ? "text-white/80" : "text-[#6d6d6d]"
119
+
120
+ const startRename = () => {
121
+ if (!onRename) return
122
+ setRenameValue(file.name)
123
+ setRenaming(true)
124
+ requestAnimationFrame(() => {
125
+ const input = renameRef.current
126
+ if (!input) return
127
+ input.focus()
128
+ const dot = file.name.lastIndexOf(".")
129
+ input.setSelectionRange(0, dot > 0 ? dot : file.name.length)
130
+ })
131
+ }
132
+
133
+ const commitRename = () => {
134
+ const trimmed = renameValue.trim()
135
+ setRenaming(false)
136
+ if (trimmed && trimmed !== file.name) onRename?.(file, trimmed)
137
+ }
138
+
139
+ return (
140
+ <>
141
+ <div
142
+ role="row"
143
+ tabIndex={0}
144
+ draggable={!!onMove && !renaming}
145
+ onDragStart={(e) => { e.dataTransfer.setData(INTERNAL_DRAG_TYPE, file.path); e.dataTransfer.effectAllowed = "move"; setDragging(true) }}
146
+ onDragEnd={() => setDragging(false)}
147
+ onClick={() => !renaming && onSelect?.(file)}
148
+ onDoubleClick={() => !renaming && onOpen?.(file)}
149
+ onKeyDown={(e) => {
150
+ if (e.key === "Enter" && selected && !renaming) { e.preventDefault(); startRename() }
151
+ if (e.key === "Escape" && renaming) setRenaming(false)
152
+ }}
153
+ onContextMenu={(e) => {
154
+ if (!hasMenu || renaming) return
155
+ e.preventDefault()
156
+ onSelect?.(file)
157
+ setMenu({ x: e.clientX, y: e.clientY })
158
+ }}
159
+ data-selected={selected || undefined}
160
+ className={cn(
161
+ "h-[24px] cursor-default select-none items-center outline-none",
162
+ selected && "!bg-[#2068d0] text-white rounded-lg",
163
+ dragging && "opacity-40",
164
+ )}
165
+ style={{ display: "grid", gridTemplateColumns: COL_GRID }}
166
+ >
167
+ <div className="flex items-center gap-1.5 min-w-0" style={{ paddingLeft: padLeft }}>
168
+ {getFileIcon(file.extension)}
169
+ {renaming ? (
170
+ <input
171
+ ref={renameRef}
172
+ value={renameValue}
173
+ onChange={(e) => setRenameValue(e.target.value)}
174
+ onKeyDown={(e) => {
175
+ if (e.key === "Enter") { e.preventDefault(); commitRename() }
176
+ if (e.key === "Escape") { e.stopPropagation(); setRenaming(false) }
177
+ }}
178
+ onBlur={commitRename}
179
+ className="flex-1 text-[13px] bg-white text-[#0d0d0d] outline-none rounded px-1 -ml-1 min-w-0 border border-[#2068d0] shadow-sm"
180
+ />
181
+ ) : (
182
+ <span className="text-[13px] truncate">{file.name}</span>
183
+ )}
184
+ </div>
185
+ <span className={cn("text-[11px] truncate px-2", sec)}>{formatFinderDate(file.dateModified)}</span>
186
+ <span className={cn("text-[11px] text-right px-2", sec)}>{formatSize(file.size)}</span>
187
+ <span className={cn("text-[11px] truncate px-2", sec)}>{getKind(file.extension)}</span>
188
+ </div>
189
+ {menu && (
190
+ <FileMenu
191
+ file={file} position={menu}
192
+ onClose={() => setMenu(null)}
193
+ onOpen={onOpen} onReveal={onReveal} onDelete={onDelete}
194
+ />
195
+ )}
196
+ </>
197
+ )
198
+ }
@@ -0,0 +1,294 @@
1
+ /**
2
+ * FilesBrowser — macOS Finder list-view clone.
3
+ * Column headers with sort, file/folder tree, status bar, drag-and-drop.
4
+ */
5
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react"
6
+ import { createPortal } from "react-dom"
7
+ import { cn, Button } from "@houston-ai/core"
8
+ import { Upload } from "lucide-react"
9
+ import type { FileEntry } from "./types"
10
+ import { useDropZone } from "./drop-zone"
11
+ import { FileRow, FolderSection, COL_GRID } from "./file-row"
12
+ import { NewFolderInput } from "./new-folder-input"
13
+ import { buildTree } from "./tree"
14
+ import { sortTree, type SortKey, type SortDirection } from "./utils"
15
+
16
+ export interface FilesBrowserProps {
17
+ files: FileEntry[]
18
+ loading?: boolean
19
+ selectedPath?: string | null
20
+ onSelect?: (file: FileEntry) => void
21
+ onOpen?: (file: FileEntry) => void
22
+ onReveal?: (file: FileEntry) => void
23
+ onDelete?: (file: FileEntry) => void
24
+ onFilesDropped?: (files: File[], targetFolder?: string) => void
25
+ /** Move a file/folder to a new location (null = root) */
26
+ onMove?: (sourcePath: string, targetFolder: string | null) => void
27
+ onRename?: (file: FileEntry, newName: string) => void
28
+ onCreateFolder?: (name: string) => void
29
+ onBrowse?: () => void
30
+ emptyTitle?: string
31
+ emptyDescription?: string
32
+ }
33
+
34
+ export function FilesBrowser({
35
+ files, loading, selectedPath: controlledSelected, onSelect, onOpen, onReveal, onDelete,
36
+ onFilesDropped, onMove, onRename, onCreateFolder, onBrowse,
37
+ emptyTitle = "No files yet",
38
+ emptyDescription = "When agents create files, they\u2019ll appear here.",
39
+ }: FilesBrowserProps) {
40
+ // Internal selection state — used when consumer doesn't control selection
41
+ const [internalSelected, setInternalSelected] = useState<string | null>(null)
42
+ const selectedPath = controlledSelected !== undefined ? controlledSelected : internalSelected
43
+ const handleSelect = useCallback((file: FileEntry) => {
44
+ setInternalSelected(file.path)
45
+ onSelect?.(file)
46
+ }, [onSelect])
47
+
48
+ const [creatingFolder, setCreatingFolder] = useState(false)
49
+ const [bgMenu, setBgMenu] = useState<{ x: number; y: number } | null>(null)
50
+
51
+ const [folderDropTarget, setFolderDropTarget] = useState<string | null>(null)
52
+ const folderTargetRef = useRef<string | null>(null)
53
+ const [sortKey, setSortKey] = useState<SortKey>("name")
54
+ const [sortDir, setSortDir] = useState<SortDirection>("asc")
55
+
56
+ const onDragActive = useCallback((f: string | null) => {
57
+ setFolderDropTarget(f)
58
+ folderTargetRef.current = f
59
+ }, [])
60
+
61
+ // Wrap callbacks so container drops target the hovered folder (or root)
62
+ const handleDrop = useCallback((files: File[]) => {
63
+ onFilesDropped?.(files, folderTargetRef.current ?? undefined)
64
+ }, [onFilesDropped])
65
+ const handleMove = useCallback((src: string) => {
66
+ onMove?.(src, folderTargetRef.current)
67
+ }, [onMove])
68
+ const { isDragging, dragHandlers } = useDropZone(handleDrop, handleMove)
69
+
70
+ const isEmpty = !loading && files.length === 0
71
+ const isRootTarget = isDragging && folderDropTarget === null
72
+
73
+ const handleSort = useCallback((key: SortKey) => {
74
+ setSortKey((prev) => {
75
+ if (prev === key) { setSortDir((d) => (d === "asc" ? "desc" : "asc")); return prev }
76
+ setSortDir("asc")
77
+ return key
78
+ })
79
+ }, [])
80
+
81
+ const tree = useMemo(() => {
82
+ if (isEmpty) return null
83
+ return sortTree(buildTree(files), sortKey, sortDir)
84
+ }, [files, isEmpty, sortKey, sortDir])
85
+
86
+ const fileCount = useMemo(() => {
87
+ if (!tree) return 0
88
+ let count = 0
89
+ const walk = (children: typeof tree.children) => {
90
+ for (const c of children) {
91
+ count++
92
+ if (c.kind === "folder") walk(c.children)
93
+ }
94
+ }
95
+ walk(tree.children)
96
+ return count
97
+ }, [tree])
98
+
99
+ if (isEmpty) {
100
+ return (
101
+ <div className="flex-1 flex flex-col items-center pt-[20vh] gap-4 px-8">
102
+ <div className="space-y-2 text-center max-w-md">
103
+ <h1 className="text-2xl font-semibold tracking-tight">{emptyTitle}</h1>
104
+ <p className="text-sm text-muted-foreground">{emptyDescription}</p>
105
+ </div>
106
+ {onBrowse && (
107
+ <Button variant="default" size="sm" onClick={onBrowse}>
108
+ <Upload className="size-4 mr-1.5" /> Browse files
109
+ </Button>
110
+ )}
111
+ </div>
112
+ )
113
+ }
114
+
115
+ return (
116
+ <div
117
+ className="relative flex flex-col overflow-hidden bg-white border border-[#e0e0e0] rounded-xl m-4 h-[calc(100%-2rem)]"
118
+ {...(onFilesDropped || onMove ? dragHandlers : {})}
119
+ >
120
+ <div className="h-[24px] shrink-0 border-b border-[#e5e5e5] bg-[#fafafa] select-none flex items-center rounded-t-xl">
121
+ <div className="flex-1 min-w-0 items-center h-full" style={{ display: "grid", gridTemplateColumns: COL_GRID }}>
122
+ <HeaderCell label="Name" col="name" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} className="pl-7" />
123
+ <HeaderCell label="Date Modified" col="dateModified" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} />
124
+ <HeaderCell label="Size" col="size" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} className="justify-end" />
125
+ <HeaderCell label="Kind" col="kind" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} last />
126
+ </div>
127
+ </div>
128
+
129
+ <div
130
+ className="flex-1 flex flex-col overflow-y-auto px-1"
131
+ style={{
132
+ backgroundColor: isRootTarget ? "rgba(0,122,255,0.06)" : undefined,
133
+ }}
134
+ onClick={(e) => {
135
+ if (e.target === e.currentTarget) {
136
+ setInternalSelected(null)
137
+ setBgMenu(null)
138
+ }
139
+ }}
140
+ onContextMenu={(e) => {
141
+ if (e.target === e.currentTarget && onCreateFolder) {
142
+ e.preventDefault()
143
+ setInternalSelected(null)
144
+ setBgMenu({ x: e.clientX, y: e.clientY })
145
+ }
146
+ }}
147
+ >
148
+ {loading ? (
149
+ <div className="flex items-center justify-center py-16">
150
+ <p className="text-sm text-muted-foreground/50">Loading\u2026</p>
151
+ </div>
152
+ ) : (
153
+ <>
154
+ <div className="shrink-0 [&>:nth-child(even)]:bg-[#f5f5f5] [&>:nth-child(even)]:rounded-lg">
155
+ {creatingFolder && (
156
+ <NewFolderInput
157
+ onConfirm={(n) => { onCreateFolder?.(n); setCreatingFolder(false) }}
158
+ onCancel={() => setCreatingFolder(false)}
159
+ />
160
+ )}
161
+ {tree?.children.map((child) =>
162
+ child.kind === "folder" ? (
163
+ <FolderSection
164
+ key={child.path} node={child} depth={0}
165
+ selectedPath={selectedPath} onSelect={handleSelect}
166
+ onOpen={onOpen} onReveal={onReveal} onDelete={onDelete}
167
+ onRename={onRename}
168
+ onFilesDropped={onFilesDropped} onDragActive={onDragActive} onMove={onMove}
169
+ />
170
+ ) : (
171
+ <FileRow
172
+ key={child.entry.path} file={child.entry}
173
+ selected={selectedPath === child.entry.path}
174
+ onSelect={handleSelect} onOpen={onOpen}
175
+ onReveal={onReveal} onDelete={onDelete} onRename={onRename} onMove={onMove}
176
+ />
177
+ ),
178
+ )}
179
+ </div>
180
+ <FillerStripes
181
+ startIndex={fileCount}
182
+ onDeselect={() => { setInternalSelected(null); setBgMenu(null) }}
183
+ onContextMenu={onCreateFolder ? (e) => {
184
+ e.preventDefault()
185
+ setInternalSelected(null)
186
+ setBgMenu({ x: e.clientX, y: e.clientY })
187
+ } : undefined}
188
+ />
189
+ </>
190
+ )}
191
+ </div>
192
+
193
+ {bgMenu && (
194
+ <BgContextMenu
195
+ position={bgMenu}
196
+ onNewFolder={() => { setCreatingFolder(true); setBgMenu(null) }}
197
+ onClose={() => setBgMenu(null)}
198
+ />
199
+ )}
200
+ </div>
201
+ )
202
+ }
203
+
204
+ /** Fills remaining vertical space with real rounded stripe divs. */
205
+ function FillerStripes({ startIndex, onDeselect, onContextMenu }: {
206
+ startIndex: number
207
+ onDeselect: () => void
208
+ onContextMenu?: (e: React.MouseEvent) => void
209
+ }) {
210
+ const containerRef = useRef<HTMLDivElement>(null)
211
+ const [count, setCount] = useState(0)
212
+
213
+ useEffect(() => {
214
+ const el = containerRef.current
215
+ if (!el) return
216
+ const update = () => {
217
+ const h = el.clientHeight
218
+ setCount(Math.ceil(h / 24))
219
+ }
220
+ update()
221
+ const obs = new ResizeObserver(update)
222
+ obs.observe(el)
223
+ return () => obs.disconnect()
224
+ }, [])
225
+
226
+ return (
227
+ <div ref={containerRef} className="flex-1 min-h-0">
228
+ {Array.from({ length: count }, (_, i) => (
229
+ <div
230
+ key={i}
231
+ className={cn("h-[24px]", (startIndex + i) % 2 === 1 && "bg-[#f5f5f5] rounded-lg")}
232
+ onClick={onDeselect}
233
+ onContextMenu={onContextMenu}
234
+ />
235
+ ))}
236
+ </div>
237
+ )
238
+ }
239
+
240
+ function BgContextMenu({ position, onNewFolder, onClose }: {
241
+ position: { x: number; y: number }
242
+ onNewFolder: () => void
243
+ onClose: () => void
244
+ }) {
245
+ return createPortal(
246
+ <>
247
+ <div className="fixed inset-0 z-50" onClick={onClose} onContextMenu={(e) => { e.preventDefault(); onClose() }} />
248
+ <div
249
+ className="fixed z-50 bg-white/95 backdrop-blur-xl border border-black/10 rounded-lg shadow-lg py-1 min-w-[160px]"
250
+ style={{ left: position.x, top: position.y }}
251
+ >
252
+ <button
253
+ onClick={onNewFolder}
254
+ className="w-full text-left px-3 py-1.5 text-[13px] hover:bg-[#2068d0] hover:text-white rounded-md mx-0.5"
255
+ style={{ width: "calc(100% - 4px)" }}
256
+ >
257
+ New Folder
258
+ </button>
259
+ </div>
260
+ </>,
261
+ document.body,
262
+ )
263
+ }
264
+
265
+ function HeaderCell({ label, col, sortKey, sortDir, onSort, className, last }: {
266
+ label: string
267
+ col: SortKey
268
+ sortKey: SortKey
269
+ sortDir: SortDirection
270
+ onSort: (key: SortKey) => void
271
+ className?: string
272
+ last?: boolean
273
+ }) {
274
+ const active = sortKey === col
275
+ return (
276
+ <button
277
+ onClick={() => onSort(col)}
278
+ className={cn(
279
+ "flex items-center h-full px-2 text-[11px] font-medium text-[#6d6d6d] hover:bg-[#eaeaea] transition-colors",
280
+ !last && "border-r border-[#e5e5e5]",
281
+ className,
282
+ )}
283
+ >
284
+ <span className="truncate">{label}</span>
285
+ {active && (
286
+ <svg className="size-[6px] ml-1 shrink-0" viewBox="0 0 8 5" fill="#6d6d6d">
287
+ {sortDir === "asc"
288
+ ? <path d="M0 5L4 0L8 5Z" />
289
+ : <path d="M0 0L4 5L8 0Z" />}
290
+ </svg>
291
+ )}
292
+ </button>
293
+ )
294
+ }
@@ -0,0 +1,175 @@
1
+ /**
2
+ * macOS Finder-style file icons and disclosure triangle.
3
+ * SVGs designed to match Finder's 16x16 list-view icons.
4
+ */
5
+ import type { ReactNode } from "react"
6
+ import { cn } from "@houston-ai/core"
7
+
8
+ const IC = "size-4 shrink-0"
9
+
10
+ // Shared document outline
11
+ const BODY =
12
+ "M3.5 1.5C3.5 1.22 3.72 1 4 1H10L13 4V14.5C13 14.78 12.78 15 12.5 15H4C3.72 15 3.5 14.78 3.5 14.5V1.5Z"
13
+ const FOLD =
14
+ "M10 1L13 4H10.5C10.22 4 10 3.78 10 3.5V1Z"
15
+
16
+ function DocBase({ children }: { children?: ReactNode }) {
17
+ return (
18
+ <svg className={IC} viewBox="0 0 16 16" fill="none">
19
+ <path d={BODY} fill="white" stroke="#BEBEBE" strokeWidth="0.6" />
20
+ <path
21
+ d={FOLD}
22
+ fill="#E8E8E8"
23
+ stroke="#BEBEBE"
24
+ strokeWidth="0.6"
25
+ strokeLinejoin="round"
26
+ />
27
+ {children}
28
+ </svg>
29
+ )
30
+ }
31
+
32
+ /** Blue macOS folder */
33
+ export function FolderIcon() {
34
+ return (
35
+ <svg className={IC} viewBox="0 0 16 16" fill="none">
36
+ <path
37
+ d="M1.5 3C1.5 2.45 1.95 2 2.5 2H6.29L7.79 3.5H13.5C14.05 3.5 14.5 3.95 14.5 4.5V13C14.5 13.55 14.05 14 13.5 14H2.5C1.95 14 1.5 13.55 1.5 13V3Z"
38
+ fill="#A0D0F8"
39
+ />
40
+ <path
41
+ d="M1.5 5.5C1.5 4.95 1.95 4.5 2.5 4.5H13.5C14.05 4.5 14.5 4.95 14.5 5.5V13C14.5 13.55 14.05 14 13.5 14H2.5C1.95 14 1.5 13.55 1.5 13V5.5Z"
42
+ fill="#5DB5F5"
43
+ />
44
+ </svg>
45
+ )
46
+ }
47
+
48
+ /** White document (generic) */
49
+ export function DocumentIcon() {
50
+ return <DocBase />
51
+ }
52
+
53
+ /** Red-badged PDF document */
54
+ export function PdfIcon() {
55
+ return (
56
+ <DocBase>
57
+ <rect x="4.5" y="9" width="7" height="4.5" rx="0.5" fill="#E5252A" />
58
+ <text
59
+ x="8"
60
+ y="12.5"
61
+ textAnchor="middle"
62
+ fill="white"
63
+ fontSize="4"
64
+ fontWeight="700"
65
+ fontFamily="system-ui, sans-serif"
66
+ >
67
+ PDF
68
+ </text>
69
+ </DocBase>
70
+ )
71
+ }
72
+
73
+ /** Image document with landscape */
74
+ export function ImageDocIcon() {
75
+ return (
76
+ <DocBase>
77
+ <path d="M5 12.5L7.5 8.5L9 10.5L10 9L12 12.5H5Z" fill="#4CAF50" opacity="0.6" />
78
+ <circle cx="10.5" cy="7" r="1" fill="#FFC107" opacity="0.6" />
79
+ </DocBase>
80
+ )
81
+ }
82
+
83
+ /** Code document with angle brackets */
84
+ export function CodeDocIcon() {
85
+ return (
86
+ <DocBase>
87
+ <path
88
+ d="M7 7L5.5 9.5L7 12"
89
+ stroke="#777"
90
+ strokeWidth="0.8"
91
+ fill="none"
92
+ strokeLinecap="round"
93
+ strokeLinejoin="round"
94
+ />
95
+ <path
96
+ d="M9.5 7L11 9.5L9.5 12"
97
+ stroke="#777"
98
+ strokeWidth="0.8"
99
+ fill="none"
100
+ strokeLinecap="round"
101
+ strokeLinejoin="round"
102
+ />
103
+ </DocBase>
104
+ )
105
+ }
106
+
107
+ /** Spreadsheet with green grid */
108
+ export function SheetDocIcon() {
109
+ return (
110
+ <DocBase>
111
+ <rect x="5" y="7" width="6.5" height="5.5" rx="0.3" fill="none" stroke="#34A853" strokeWidth="0.6" />
112
+ <line x1="7.2" y1="7" x2="7.2" y2="12.5" stroke="#34A853" strokeWidth="0.4" />
113
+ <line x1="9.3" y1="7" x2="9.3" y2="12.5" stroke="#34A853" strokeWidth="0.4" />
114
+ <line x1="5" y1="9" x2="11.5" y2="9" stroke="#34A853" strokeWidth="0.4" />
115
+ <line x1="5" y1="10.8" x2="11.5" y2="10.8" stroke="#34A853" strokeWidth="0.4" />
116
+ </DocBase>
117
+ )
118
+ }
119
+
120
+ /** Archive/zip document */
121
+ export function ArchiveDocIcon() {
122
+ return (
123
+ <DocBase>
124
+ <rect x="7.2" y="5.5" width="1.6" height="1" rx="0.2" fill="#999" />
125
+ <rect x="7.2" y="7.5" width="1.6" height="1" rx="0.2" fill="#999" />
126
+ <rect x="7.2" y="9.5" width="1.6" height="1" rx="0.2" fill="#999" />
127
+ <rect x="6.8" y="11.5" width="2.4" height="2" rx="0.5" fill="#999" />
128
+ </DocBase>
129
+ )
130
+ }
131
+
132
+ /** Chevron disclosure indicator (rotates 90deg when open) */
133
+ export function DisclosureChevron({
134
+ open,
135
+ className,
136
+ }: {
137
+ open: boolean
138
+ className?: string
139
+ }) {
140
+ return (
141
+ <svg
142
+ className={cn(
143
+ "size-[10px] shrink-0 transition-transform duration-150",
144
+ open && "rotate-90",
145
+ className,
146
+ )}
147
+ viewBox="0 0 10 10"
148
+ fill="none"
149
+ stroke="#8e8e8e"
150
+ strokeWidth="1.5"
151
+ strokeLinecap="round"
152
+ strokeLinejoin="round"
153
+ >
154
+ <path d="M3.5 1.5L7 5L3.5 8.5" />
155
+ </svg>
156
+ )
157
+ }
158
+
159
+ // --- Extension mapping ---
160
+
161
+ const IMAGE_EXT = new Set(["png", "jpg", "jpeg", "gif", "svg", "webp", "ico", "bmp", "tiff", "heic"])
162
+ const CODE_EXT = new Set(["js", "ts", "tsx", "jsx", "rs", "py", "go", "rb", "sh", "c", "cpp", "h", "java", "swift", "kt", "html", "css", "scss", "vue", "svelte"])
163
+ const SHEET_EXT = new Set(["xlsx", "xls", "csv", "numbers", "ods"])
164
+ const ARCHIVE_EXT = new Set(["zip", "gz", "tar", "7z", "rar", "dmg", "iso"])
165
+
166
+ /** Return the appropriate Finder-style icon for a file extension. */
167
+ export function getFileIcon(extension: string): ReactNode {
168
+ const ext = extension.toLowerCase()
169
+ if (ext === "pdf") return <PdfIcon />
170
+ if (IMAGE_EXT.has(ext)) return <ImageDocIcon />
171
+ if (CODE_EXT.has(ext)) return <CodeDocIcon />
172
+ if (SHEET_EXT.has(ext)) return <SheetDocIcon />
173
+ if (ARCHIVE_EXT.has(ext)) return <ArchiveDocIcon />
174
+ return <DocumentIcon />
175
+ }
package/src/index.ts ADDED
@@ -0,0 +1,18 @@
1
+ // Types
2
+ export type { FileEntry, InstructionFile } from "./types"
3
+ export type { FolderNode, FileNode, TreeNode } from "./tree"
4
+ export { buildTree, countFiles } from "./tree"
5
+
6
+ // Components
7
+ export { FilesBrowser } from "./files-browser"
8
+ export type { FilesBrowserProps } from "./files-browser"
9
+
10
+ export { InstructionsPanel } from "./instructions-panel"
11
+ export type { InstructionsPanelProps } from "./instructions-panel"
12
+
13
+ // Hooks
14
+ export { useDropZone, useFolderDropTarget, INTERNAL_DRAG_TYPE } from "./drop-zone"
15
+
16
+ // Utilities
17
+ export { formatSize, formatFinderDate, getKind } from "./utils"
18
+ export type { SortKey, SortDirection } from "./utils"
@@ -0,0 +1,97 @@
1
+ /**
2
+ * InstructionsPanel — editable instruction files for an agent workspace.
3
+ * Visual style matches Houston's ContextTab exactly: labeled textareas
4
+ * with auto-save on blur, bg-[#f9f9f9], subtle borders.
5
+ */
6
+ import { useEffect, useState } from "react"
7
+ import type { InstructionFile } from "./types"
8
+
9
+ export interface InstructionsPanelProps {
10
+ /** Instruction files to display */
11
+ files: InstructionFile[]
12
+ /** Called when a file is edited and the textarea loses focus */
13
+ onSave: (name: string, content: string) => Promise<void>
14
+ /** Title for empty state */
15
+ emptyTitle?: string
16
+ /** Description for empty state */
17
+ emptyDescription?: string
18
+ }
19
+
20
+ export function InstructionsPanel({
21
+ files,
22
+ onSave,
23
+ emptyTitle = "No instructions yet",
24
+ emptyDescription = "Add a CLAUDE.md to this workspace to configure how the agent behaves.",
25
+ }: InstructionsPanelProps) {
26
+ if (files.length === 0) {
27
+ return (
28
+ <div className="flex-1 flex flex-col items-center pt-[20vh] gap-4 px-8">
29
+ <div className="space-y-2 text-center max-w-md">
30
+ <h1 className="text-2xl font-semibold text-foreground tracking-tight">
31
+ {emptyTitle}
32
+ </h1>
33
+ <p className="text-sm text-muted-foreground">{emptyDescription}</p>
34
+ </div>
35
+ </div>
36
+ )
37
+ }
38
+
39
+ return (
40
+ <div className="flex-1 overflow-y-auto">
41
+ <div className="px-6 py-6">
42
+ <div className="space-y-4">
43
+ {files.map((file) => (
44
+ <InstructionField
45
+ key={file.name}
46
+ file={file}
47
+ onSave={(content) => onSave(file.name, content)}
48
+ />
49
+ ))}
50
+ </div>
51
+ </div>
52
+ </div>
53
+ )
54
+ }
55
+
56
+ function InstructionField({
57
+ file,
58
+ onSave,
59
+ }: {
60
+ file: InstructionFile
61
+ onSave: (content: string) => Promise<void>
62
+ }) {
63
+ const [value, setValue] = useState(file.content)
64
+ const [saving, setSaving] = useState(false)
65
+
66
+ useEffect(() => {
67
+ setValue(file.content)
68
+ }, [file.content])
69
+
70
+ const handleBlur = async () => {
71
+ if (value !== file.content) {
72
+ setSaving(true)
73
+ await onSave(value)
74
+ setSaving(false)
75
+ }
76
+ }
77
+
78
+ return (
79
+ <div className="space-y-1.5">
80
+ <label className="text-xs font-medium text-muted-foreground/50 px-1 flex items-center gap-2">
81
+ {file.label}
82
+ {saving && (
83
+ <span className="text-[10px] text-muted-foreground/30">
84
+ Saving...
85
+ </span>
86
+ )}
87
+ </label>
88
+ <textarea
89
+ value={value}
90
+ onChange={(e) => setValue(e.target.value)}
91
+ onBlur={handleBlur}
92
+ rows={Math.max(4, value.split("\n").length + 1)}
93
+ className="w-full text-sm text-foreground leading-relaxed bg-[#f9f9f9] outline-none rounded-xl px-4 py-3 border border-black/[0.04] hover:border-black/[0.1] focus:border-black/[0.15] focus:bg-white transition-all duration-200 resize-none placeholder:text-muted-foreground/30"
94
+ />
95
+ </div>
96
+ )
97
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Inline new-folder input, styled as a selected folder row.
3
+ */
4
+ import { useRef, useState } from "react"
5
+ import { FolderIcon, DisclosureChevron } from "./finder-icons"
6
+ import { COL_GRID } from "./file-row"
7
+
8
+ export function NewFolderInput({ onConfirm, onCancel }: {
9
+ onConfirm: (name: string) => void
10
+ onCancel: () => void
11
+ }) {
12
+ const [value, setValue] = useState("")
13
+ const committed = useRef(false)
14
+
15
+ const commit = () => {
16
+ if (committed.current) return
17
+ const trimmed = value.trim()
18
+ if (trimmed) {
19
+ committed.current = true
20
+ onConfirm(trimmed)
21
+ } else {
22
+ onCancel()
23
+ }
24
+ }
25
+
26
+ return (
27
+ <div
28
+ className="h-[24px] bg-[#2068d0] rounded-lg items-center"
29
+ style={{ display: "grid", gridTemplateColumns: COL_GRID }}
30
+ >
31
+ <div className="flex items-center gap-1.5 min-w-0 pl-3">
32
+ <DisclosureChevron open={false} className="invisible" />
33
+ <FolderIcon />
34
+ <input
35
+ autoFocus
36
+ value={value}
37
+ onChange={(e) => setValue(e.target.value)}
38
+ onKeyDown={(e) => {
39
+ if (e.key === "Enter") commit()
40
+ if (e.key === "Escape") onCancel()
41
+ }}
42
+ onBlur={commit}
43
+ placeholder="untitled folder"
44
+ className="flex-1 text-[13px] bg-transparent text-white outline-none placeholder:text-white/50 min-w-0"
45
+ />
46
+ </div>
47
+ <span />
48
+ <span />
49
+ <span className="text-[11px] text-white/70 px-2">Folder</span>
50
+ </div>
51
+ )
52
+ }
package/src/tree.ts ADDED
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Tree types and builder for the FilesBrowser hierarchy.
3
+ * Converts a flat list of FileEntry (with relative paths) into a nested tree.
4
+ */
5
+ import type { FileEntry } from "./types"
6
+
7
+ export interface FolderNode {
8
+ kind: "folder"
9
+ name: string
10
+ /** Relative path from workspace root (e.g. "2025/Investments - Fidelity") */
11
+ path: string
12
+ children: TreeNode[]
13
+ }
14
+
15
+ export interface FileNode {
16
+ kind: "file"
17
+ entry: FileEntry
18
+ }
19
+
20
+ export type TreeNode = FolderNode | FileNode
21
+
22
+ /**
23
+ * Convert a flat list of FileEntry (with relative paths) into a tree.
24
+ * Path separators are "/" (as produced by the Rust backend).
25
+ *
26
+ * Example: ["2025/Investments - Fidelity/statement.pdf", "2025/W2.pdf"]
27
+ * Produces: root → 2025 → [Investments - Fidelity → [statement.pdf], W2.pdf]
28
+ */
29
+ export function buildTree(files: FileEntry[]): FolderNode {
30
+ const root: FolderNode = { kind: "folder", name: "", path: "", children: [] }
31
+
32
+ for (const file of files) {
33
+ // Directory entries become folder nodes directly
34
+ if (file.is_directory) {
35
+ const parts = file.path.split("/")
36
+ let node = root
37
+ for (const segment of parts) {
38
+ let child = node.children.find(
39
+ (c): c is FolderNode => c.kind === "folder" && c.name === segment,
40
+ )
41
+ if (!child) {
42
+ const folderPath = parts.slice(0, parts.indexOf(segment) + 1).join("/")
43
+ child = { kind: "folder", name: segment, path: folderPath, children: [] }
44
+ node.children.push(child)
45
+ }
46
+ node = child
47
+ }
48
+ continue
49
+ }
50
+
51
+ const parts = file.path.split("/")
52
+ let node = root
53
+ for (let i = 0; i < parts.length - 1; i++) {
54
+ const segment = parts[i]
55
+ const folderPath = parts.slice(0, i + 1).join("/")
56
+ let child = node.children.find(
57
+ (c): c is FolderNode => c.kind === "folder" && c.name === segment,
58
+ )
59
+ if (!child) {
60
+ child = { kind: "folder", name: segment, path: folderPath, children: [] }
61
+ node.children.push(child)
62
+ }
63
+ node = child
64
+ }
65
+ node.children.push({ kind: "file", entry: file })
66
+ }
67
+
68
+ return root
69
+ }
70
+
71
+ /** Count all file descendants of a folder node (recursively). */
72
+ export function countFiles(node: FolderNode): number {
73
+ let count = 0
74
+ for (const child of node.children) {
75
+ if (child.kind === "file") count++
76
+ else count += countFiles(child)
77
+ }
78
+ return count
79
+ }
package/src/types.ts ADDED
@@ -0,0 +1,27 @@
1
+ // --- Files browser ---
2
+
3
+ export interface FileEntry {
4
+ /** Relative path from workspace root (e.g., "docs/readme.md") */
5
+ path: string
6
+ /** File name with extension */
7
+ name: string
8
+ /** File extension without dot (e.g., "md", "pdf") */
9
+ extension: string
10
+ /** File size in bytes */
11
+ size: number
12
+ /** Whether this entry is a directory */
13
+ is_directory?: boolean
14
+ /** Last modified timestamp in milliseconds (Date.now() format) */
15
+ dateModified?: number
16
+ }
17
+
18
+ // --- Instructions panel ---
19
+
20
+ export interface InstructionFile {
21
+ /** File name (e.g., "CLAUDE.md") */
22
+ name: string
23
+ /** Human-readable label shown above the field (e.g., "CLAUDE.md") */
24
+ label: string
25
+ /** Current file content */
26
+ content: string
27
+ }
package/src/utils.ts ADDED
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Finder-style formatting and sorting utilities.
3
+ */
4
+ import type { FolderNode, FileNode } from "./tree"
5
+
6
+ /** Format bytes like Finder (SI units: 1000, not 1024). */
7
+ export function formatSize(bytes: number): string {
8
+ if (bytes === 0) return "Zero bytes"
9
+ if (bytes < 1000) return `${bytes} bytes`
10
+ if (bytes < 1000000) return `${Math.round(bytes / 1000)} KB`
11
+ if (bytes < 1000000000) return `${(bytes / 1000000).toFixed(1)} MB`
12
+ return `${(bytes / 1000000000).toFixed(1)} GB`
13
+ }
14
+
15
+ /** Format a timestamp like Finder: "Today at 3:24 PM", "Yesterday", "Apr 3 at 2:30 PM". */
16
+ export function formatFinderDate(timestamp?: number): string {
17
+ if (!timestamp) return "\u2014"
18
+ const date = new Date(timestamp)
19
+ const now = new Date()
20
+ const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
21
+ const yesterday = new Date(today.getTime() - 86400000)
22
+ const time = date.toLocaleTimeString("en-US", {
23
+ hour: "numeric",
24
+ minute: "2-digit",
25
+ })
26
+ if (date >= today) return `Today at ${time}`
27
+ if (date >= yesterday) return `Yesterday at ${time}`
28
+ if (date.getFullYear() === now.getFullYear()) {
29
+ return (
30
+ date.toLocaleDateString("en-US", { month: "short", day: "numeric" }) +
31
+ ` at ${time}`
32
+ )
33
+ }
34
+ return date.toLocaleDateString("en-US", {
35
+ month: "short",
36
+ day: "numeric",
37
+ year: "numeric",
38
+ })
39
+ }
40
+
41
+ const KIND_MAP: Record<string, string> = {
42
+ pdf: "PDF Document",
43
+ md: "Markdown",
44
+ txt: "Plain Text",
45
+ rtf: "Rich Text Document",
46
+ png: "PNG Image",
47
+ jpg: "JPEG Image",
48
+ jpeg: "JPEG Image",
49
+ gif: "GIF Image",
50
+ svg: "SVG Image",
51
+ webp: "WebP Image",
52
+ heic: "HEIC Image",
53
+ mp3: "MP3 Audio",
54
+ wav: "WAV Audio",
55
+ aac: "AAC Audio",
56
+ mp4: "MPEG-4 Movie",
57
+ mov: "QuickTime Movie",
58
+ json: "JSON",
59
+ js: "JavaScript",
60
+ ts: "TypeScript",
61
+ tsx: "TypeScript JSX",
62
+ jsx: "JavaScript JSX",
63
+ css: "CSS Stylesheet",
64
+ html: "HTML Document",
65
+ xml: "XML Document",
66
+ yaml: "YAML",
67
+ yml: "YAML",
68
+ toml: "TOML",
69
+ rs: "Rust Source",
70
+ py: "Python Script",
71
+ go: "Go Source",
72
+ rb: "Ruby Script",
73
+ sh: "Shell Script",
74
+ zip: "ZIP Archive",
75
+ gz: "GZ Archive",
76
+ tar: "Tar Archive",
77
+ dmg: "Apple Disk Image",
78
+ xlsx: "Excel Spreadsheet",
79
+ xls: "Excel Spreadsheet",
80
+ csv: "CSV Document",
81
+ doc: "Word Document",
82
+ docx: "Word Document",
83
+ }
84
+
85
+ /** Get Finder-style "Kind" string from file extension. */
86
+ export function getKind(extension: string): string {
87
+ return KIND_MAP[extension.toLowerCase()] || `${extension.toUpperCase()} File`
88
+ }
89
+
90
+ // --- Sort ---
91
+
92
+ export type SortKey = "name" | "dateModified" | "size" | "kind"
93
+ export type SortDirection = "asc" | "desc"
94
+
95
+ /** Recursively sort a tree (folders first, then by selected column). */
96
+ export function sortTree(
97
+ node: FolderNode,
98
+ key: SortKey,
99
+ direction: SortDirection,
100
+ ): FolderNode {
101
+ const sorted = [...node.children].sort((a, b) => {
102
+ if (a.kind !== b.kind) return a.kind === "folder" ? -1 : 1
103
+ if (a.kind === "folder" && b.kind === "folder") {
104
+ const cmp = a.name.localeCompare(b.name)
105
+ return direction === "asc" ? cmp : -cmp
106
+ }
107
+ const fa = (a as FileNode).entry
108
+ const fb = (b as FileNode).entry
109
+ let cmp = 0
110
+ switch (key) {
111
+ case "name":
112
+ cmp = fa.name.localeCompare(fb.name)
113
+ break
114
+ case "dateModified":
115
+ cmp = (fa.dateModified ?? 0) - (fb.dateModified ?? 0)
116
+ break
117
+ case "size":
118
+ cmp = fa.size - fb.size
119
+ break
120
+ case "kind":
121
+ cmp = getKind(fa.extension).localeCompare(getKind(fb.extension))
122
+ break
123
+ }
124
+ return direction === "asc" ? cmp : -cmp
125
+ })
126
+ return {
127
+ ...node,
128
+ children: sorted.map((c) =>
129
+ c.kind === "folder" ? sortTree(c, key, direction) : c,
130
+ ),
131
+ }
132
+ }