@houston-ai/workspace 0.2.0
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 +21 -0
- package/src/drop-zone.tsx +90 -0
- package/src/file-menu.tsx +68 -0
- package/src/file-row.tsx +198 -0
- package/src/files-browser.tsx +294 -0
- package/src/finder-icons.tsx +175 -0
- package/src/index.ts +18 -0
- package/src/instructions-panel.tsx +97 -0
- package/src/new-folder-input.tsx +52 -0
- package/src/tree.ts +79 -0
- package/src/types.ts +27 -0
- package/src/utils.ts +132 -0
package/package.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@houston-ai/workspace",
|
|
3
|
+
"version": "0.2.0",
|
|
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-houston-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
|
+
}
|
package/src/file-row.tsx
ADDED
|
@@ -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
|
+
}
|