@deck-ui/workspace 0.1.2 → 0.3.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 +1 -1
- package/src/drop-zone.tsx +92 -0
- package/src/file-row.tsx +208 -0
- package/src/files-browser.tsx +174 -238
- package/src/index.ts +3 -0
package/package.json
CHANGED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Drop zone hooks for drag-and-drop file imports.
|
|
3
|
+
* Container-level highlight + folder-level targeting (Finder-style).
|
|
4
|
+
*/
|
|
5
|
+
import { useCallback, useRef, useState } from "react"
|
|
6
|
+
|
|
7
|
+
/** Tracks whether files are being dragged over the container. */
|
|
8
|
+
export function useDropZone(
|
|
9
|
+
onFilesDropped?: (files: File[], targetFolder?: string) => void,
|
|
10
|
+
) {
|
|
11
|
+
const [isDragging, setIsDragging] = useState(false)
|
|
12
|
+
const counter = useRef(0)
|
|
13
|
+
|
|
14
|
+
const onDragEnter = useCallback((e: React.DragEvent) => {
|
|
15
|
+
e.preventDefault()
|
|
16
|
+
counter.current++
|
|
17
|
+
if (e.dataTransfer.types.includes("Files")) setIsDragging(true)
|
|
18
|
+
}, [])
|
|
19
|
+
|
|
20
|
+
const onDragLeave = useCallback((e: React.DragEvent) => {
|
|
21
|
+
e.preventDefault()
|
|
22
|
+
counter.current--
|
|
23
|
+
if (counter.current === 0) setIsDragging(false)
|
|
24
|
+
}, [])
|
|
25
|
+
|
|
26
|
+
const onDragOver = useCallback((e: React.DragEvent) => {
|
|
27
|
+
e.preventDefault()
|
|
28
|
+
}, [])
|
|
29
|
+
|
|
30
|
+
const onDrop = useCallback(
|
|
31
|
+
(e: React.DragEvent) => {
|
|
32
|
+
e.preventDefault()
|
|
33
|
+
counter.current = 0
|
|
34
|
+
setIsDragging(false)
|
|
35
|
+
if (!onFilesDropped) return
|
|
36
|
+
const files = Array.from(e.dataTransfer.files)
|
|
37
|
+
if (files.length > 0) onFilesDropped(files)
|
|
38
|
+
},
|
|
39
|
+
[onFilesDropped],
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
isDragging,
|
|
44
|
+
dragHandlers: { onDragEnter, onDragLeave, onDragOver, onDrop },
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Tracks drag-over state for an entire folder section (header + children).
|
|
50
|
+
* Stops propagation on drop so the container handler doesn't also fire.
|
|
51
|
+
*/
|
|
52
|
+
export function useFolderDropTarget(
|
|
53
|
+
folder: string,
|
|
54
|
+
onFilesDropped?: (files: File[], targetFolder?: string) => void,
|
|
55
|
+
) {
|
|
56
|
+
const [isOver, setIsOver] = useState(false)
|
|
57
|
+
const counter = useRef(0)
|
|
58
|
+
|
|
59
|
+
const onDragEnter = useCallback((e: React.DragEvent) => {
|
|
60
|
+
e.preventDefault()
|
|
61
|
+
counter.current++
|
|
62
|
+
if (e.dataTransfer.types.includes("Files")) setIsOver(true)
|
|
63
|
+
}, [])
|
|
64
|
+
|
|
65
|
+
const onDragLeave = useCallback((e: React.DragEvent) => {
|
|
66
|
+
e.preventDefault()
|
|
67
|
+
counter.current--
|
|
68
|
+
if (counter.current === 0) setIsOver(false)
|
|
69
|
+
}, [])
|
|
70
|
+
|
|
71
|
+
const onDragOver = useCallback((e: React.DragEvent) => {
|
|
72
|
+
e.preventDefault()
|
|
73
|
+
}, [])
|
|
74
|
+
|
|
75
|
+
const onDrop = useCallback(
|
|
76
|
+
(e: React.DragEvent) => {
|
|
77
|
+
e.preventDefault()
|
|
78
|
+
e.stopPropagation() // only stop propagation on drop to prevent container double-handling
|
|
79
|
+
counter.current = 0
|
|
80
|
+
setIsOver(false)
|
|
81
|
+
if (!onFilesDropped) return
|
|
82
|
+
const files = Array.from(e.dataTransfer.files)
|
|
83
|
+
if (files.length > 0) onFilesDropped(files, folder)
|
|
84
|
+
},
|
|
85
|
+
[onFilesDropped, folder],
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
isOver,
|
|
90
|
+
folderHandlers: { onDragEnter, onDragLeave, onDragOver, onDrop },
|
|
91
|
+
}
|
|
92
|
+
}
|
package/src/file-row.tsx
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File row, folder section, and file icon sub-components.
|
|
3
|
+
* Extracted from FilesBrowser to keep files under 200 lines.
|
|
4
|
+
*/
|
|
5
|
+
import { useEffect, useState } from "react"
|
|
6
|
+
import {
|
|
7
|
+
cn,
|
|
8
|
+
DropdownMenu,
|
|
9
|
+
DropdownMenuContent,
|
|
10
|
+
DropdownMenuItem,
|
|
11
|
+
DropdownMenuTrigger,
|
|
12
|
+
} from "@deck-ui/core"
|
|
13
|
+
import {
|
|
14
|
+
ChevronRight,
|
|
15
|
+
ExternalLink,
|
|
16
|
+
FileText,
|
|
17
|
+
FolderSearch,
|
|
18
|
+
Image as ImageIcon,
|
|
19
|
+
MoreVertical,
|
|
20
|
+
Trash2,
|
|
21
|
+
} from "lucide-react"
|
|
22
|
+
import type { FileEntry } from "./types"
|
|
23
|
+
import { useFolderDropTarget } from "./drop-zone"
|
|
24
|
+
|
|
25
|
+
export function FolderSection({
|
|
26
|
+
name,
|
|
27
|
+
files,
|
|
28
|
+
onOpen,
|
|
29
|
+
onReveal,
|
|
30
|
+
onDelete,
|
|
31
|
+
onFilesDropped,
|
|
32
|
+
onDragActive,
|
|
33
|
+
}: {
|
|
34
|
+
name: string
|
|
35
|
+
files: FileEntry[]
|
|
36
|
+
onOpen?: (file: FileEntry) => void
|
|
37
|
+
onReveal?: (file: FileEntry) => void
|
|
38
|
+
onDelete?: (file: FileEntry) => void
|
|
39
|
+
onFilesDropped?: (files: File[], targetFolder?: string) => void
|
|
40
|
+
onDragActive?: (folder: string | null) => void
|
|
41
|
+
}) {
|
|
42
|
+
const [open, setOpen] = useState(true)
|
|
43
|
+
const { isOver, folderHandlers } = useFolderDropTarget(name, onFilesDropped)
|
|
44
|
+
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
onDragActive?.(isOver ? name : null)
|
|
47
|
+
}, [isOver, name, onDragActive])
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<div {...(onFilesDropped ? folderHandlers : {})}>
|
|
51
|
+
<button
|
|
52
|
+
onClick={() => setOpen(!open)}
|
|
53
|
+
className="w-full flex items-center h-8 px-6 transition-colors duration-150 select-none hover:bg-secondary"
|
|
54
|
+
style={isOver ? { backgroundColor: "rgba(0,0,0,0.06)" } : undefined}
|
|
55
|
+
>
|
|
56
|
+
<ChevronRight
|
|
57
|
+
className={cn(
|
|
58
|
+
"size-3.5 text-muted-foreground/40 transition-transform duration-200 mr-2",
|
|
59
|
+
open && "rotate-90",
|
|
60
|
+
)}
|
|
61
|
+
/>
|
|
62
|
+
<span
|
|
63
|
+
className="text-xs font-medium flex-1 text-left text-muted-foreground/60"
|
|
64
|
+
style={isOver ? { color: "var(--color-foreground)" } : undefined}
|
|
65
|
+
>
|
|
66
|
+
{name}
|
|
67
|
+
</span>
|
|
68
|
+
<span className="text-[10px] text-muted-foreground/30">
|
|
69
|
+
{files.length}
|
|
70
|
+
</span>
|
|
71
|
+
</button>
|
|
72
|
+
{open && (
|
|
73
|
+
<div style={isOver ? { backgroundColor: "rgba(0,0,0,0.03)" } : undefined}>
|
|
74
|
+
{files.map((f) => (
|
|
75
|
+
<FileRow
|
|
76
|
+
key={f.path}
|
|
77
|
+
file={f}
|
|
78
|
+
indent
|
|
79
|
+
onOpen={onOpen}
|
|
80
|
+
onReveal={onReveal}
|
|
81
|
+
onDelete={onDelete}
|
|
82
|
+
/>
|
|
83
|
+
))}
|
|
84
|
+
</div>
|
|
85
|
+
)}
|
|
86
|
+
</div>
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function FileRow({
|
|
91
|
+
file,
|
|
92
|
+
indent,
|
|
93
|
+
onOpen,
|
|
94
|
+
onReveal,
|
|
95
|
+
onDelete,
|
|
96
|
+
}: {
|
|
97
|
+
file: FileEntry
|
|
98
|
+
indent?: boolean
|
|
99
|
+
onOpen?: (file: FileEntry) => void
|
|
100
|
+
onReveal?: (file: FileEntry) => void
|
|
101
|
+
onDelete?: (file: FileEntry) => void
|
|
102
|
+
}) {
|
|
103
|
+
const ext = file.extension
|
|
104
|
+
const hasActions = onOpen || onReveal || onDelete
|
|
105
|
+
|
|
106
|
+
return (
|
|
107
|
+
<button
|
|
108
|
+
onClick={() => onOpen?.(file)}
|
|
109
|
+
className={cn(
|
|
110
|
+
"w-full flex items-center h-9 px-6 hover:bg-secondary",
|
|
111
|
+
"active:bg-accent transition-colors duration-100 text-left group",
|
|
112
|
+
indent && "pl-11",
|
|
113
|
+
)}
|
|
114
|
+
>
|
|
115
|
+
<div className="flex items-center gap-2.5 flex-1 min-w-0">
|
|
116
|
+
<FileIcon extension={ext} />
|
|
117
|
+
<span className="text-[13px] text-foreground truncate">
|
|
118
|
+
{file.name}
|
|
119
|
+
</span>
|
|
120
|
+
</div>
|
|
121
|
+
<span className="w-20 text-right text-[11px] text-muted-foreground/30">
|
|
122
|
+
{formatSize(file.size)}
|
|
123
|
+
</span>
|
|
124
|
+
{hasActions && (
|
|
125
|
+
<div className="w-10 flex justify-end">
|
|
126
|
+
<DropdownMenu>
|
|
127
|
+
<DropdownMenuTrigger asChild>
|
|
128
|
+
<span
|
|
129
|
+
role="button"
|
|
130
|
+
onClick={(e) => e.stopPropagation()}
|
|
131
|
+
className="flex items-center justify-center w-6 h-6 rounded text-muted-foreground/20 hover:text-foreground hover:bg-black/[0.05] opacity-0 group-hover:opacity-100 focus:opacity-100 transition-all duration-150"
|
|
132
|
+
>
|
|
133
|
+
<MoreVertical className="size-3.5" />
|
|
134
|
+
</span>
|
|
135
|
+
</DropdownMenuTrigger>
|
|
136
|
+
<DropdownMenuContent align="end">
|
|
137
|
+
{onOpen && (
|
|
138
|
+
<DropdownMenuItem
|
|
139
|
+
onClick={(e) => {
|
|
140
|
+
e.stopPropagation()
|
|
141
|
+
onOpen(file)
|
|
142
|
+
}}
|
|
143
|
+
>
|
|
144
|
+
<ExternalLink className="size-4 mr-2" />
|
|
145
|
+
Open
|
|
146
|
+
</DropdownMenuItem>
|
|
147
|
+
)}
|
|
148
|
+
{onReveal && (
|
|
149
|
+
<DropdownMenuItem
|
|
150
|
+
onClick={(e) => {
|
|
151
|
+
e.stopPropagation()
|
|
152
|
+
onReveal(file)
|
|
153
|
+
}}
|
|
154
|
+
>
|
|
155
|
+
<FolderSearch className="size-4 mr-2" />
|
|
156
|
+
Show in Finder
|
|
157
|
+
</DropdownMenuItem>
|
|
158
|
+
)}
|
|
159
|
+
{onDelete && (
|
|
160
|
+
<DropdownMenuItem
|
|
161
|
+
onClick={(e) => {
|
|
162
|
+
e.stopPropagation()
|
|
163
|
+
onDelete(file)
|
|
164
|
+
}}
|
|
165
|
+
className="text-destructive focus:text-destructive"
|
|
166
|
+
>
|
|
167
|
+
<Trash2 className="size-4 mr-2" />
|
|
168
|
+
Delete
|
|
169
|
+
</DropdownMenuItem>
|
|
170
|
+
)}
|
|
171
|
+
</DropdownMenuContent>
|
|
172
|
+
</DropdownMenu>
|
|
173
|
+
</div>
|
|
174
|
+
)}
|
|
175
|
+
</button>
|
|
176
|
+
)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function FileIcon({ extension }: { extension: string }) {
|
|
180
|
+
if (["png", "jpg", "jpeg", "svg", "gif", "webp"].includes(extension)) {
|
|
181
|
+
return <ImageIcon className="size-4 shrink-0 text-muted-foreground/50" />
|
|
182
|
+
}
|
|
183
|
+
if (extension === "pdf") {
|
|
184
|
+
return (
|
|
185
|
+
<svg className="size-4 shrink-0" viewBox="0 0 16 16" fill="none">
|
|
186
|
+
<rect width="16" height="16" rx="3" fill="#E5252A" />
|
|
187
|
+
<text
|
|
188
|
+
x="8"
|
|
189
|
+
y="11"
|
|
190
|
+
textAnchor="middle"
|
|
191
|
+
fill="white"
|
|
192
|
+
fontSize="7"
|
|
193
|
+
fontWeight="700"
|
|
194
|
+
fontFamily="system-ui, sans-serif"
|
|
195
|
+
>
|
|
196
|
+
PDF
|
|
197
|
+
</text>
|
|
198
|
+
</svg>
|
|
199
|
+
)
|
|
200
|
+
}
|
|
201
|
+
return <FileText className="size-4 shrink-0 text-muted-foreground/50" />
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function formatSize(bytes: number): string {
|
|
205
|
+
if (bytes < 1024) return `${bytes} B`
|
|
206
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
|
207
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
|
208
|
+
}
|
package/src/files-browser.tsx
CHANGED
|
@@ -1,26 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* FilesBrowser — file browser for an agent workspace.
|
|
3
3
|
* Shows files grouped by folder with icons, sizes, and actions.
|
|
4
|
-
*
|
|
4
|
+
* Supports drag-and-drop file import (with folder targeting) and folder creation.
|
|
5
5
|
*/
|
|
6
|
-
import { useState } from "react"
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
DropdownMenu,
|
|
10
|
-
DropdownMenuContent,
|
|
11
|
-
DropdownMenuItem,
|
|
12
|
-
DropdownMenuTrigger,
|
|
13
|
-
} from "@deck-ui/core"
|
|
14
|
-
import {
|
|
15
|
-
ChevronRight,
|
|
16
|
-
ExternalLink,
|
|
17
|
-
FileText,
|
|
18
|
-
FolderSearch,
|
|
19
|
-
Image as ImageIcon,
|
|
20
|
-
MoreVertical,
|
|
21
|
-
Trash2,
|
|
22
|
-
} from "lucide-react"
|
|
6
|
+
import { useCallback, useRef, useState } from "react"
|
|
7
|
+
import { cn, Button } from "@deck-ui/core"
|
|
8
|
+
import { FolderPlus, Upload, FolderOpen } from "lucide-react"
|
|
23
9
|
import type { FileEntry } from "./types"
|
|
10
|
+
import { useDropZone } from "./drop-zone"
|
|
11
|
+
import { FileRow, FolderSection } from "./file-row"
|
|
24
12
|
|
|
25
13
|
export interface FilesBrowserProps {
|
|
26
14
|
/** Files to display */
|
|
@@ -33,6 +21,14 @@ export interface FilesBrowserProps {
|
|
|
33
21
|
onReveal?: (file: FileEntry) => void
|
|
34
22
|
/** Called when delete is selected */
|
|
35
23
|
onDelete?: (file: FileEntry) => void
|
|
24
|
+
/** Called when files are dropped. targetFolder is set when dropped on a folder. */
|
|
25
|
+
onFilesDropped?: (files: File[], targetFolder?: string) => void
|
|
26
|
+
/** Called when the user creates a new folder via the inline input */
|
|
27
|
+
onCreateFolder?: (name: string) => void
|
|
28
|
+
/** Called when "Browse files" is clicked — wire to native file picker (e.g. tauri-plugin-dialog). */
|
|
29
|
+
onBrowse?: () => void
|
|
30
|
+
/** Called when "Open folder" is clicked — wire to reveal workspace dir (e.g. shell open). */
|
|
31
|
+
onRevealWorkspace?: () => void
|
|
36
32
|
/** Title for empty state */
|
|
37
33
|
emptyTitle?: string
|
|
38
34
|
/** Description for empty state */
|
|
@@ -45,247 +41,187 @@ export function FilesBrowser({
|
|
|
45
41
|
onOpen,
|
|
46
42
|
onReveal,
|
|
47
43
|
onDelete,
|
|
44
|
+
onFilesDropped,
|
|
45
|
+
onCreateFolder,
|
|
46
|
+
onBrowse,
|
|
47
|
+
onRevealWorkspace,
|
|
48
48
|
emptyTitle = "Your work shows up here",
|
|
49
49
|
emptyDescription = "When agents create files, they'll appear here for you to open and review.",
|
|
50
50
|
}: FilesBrowserProps) {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
</h1>
|
|
58
|
-
<p className="text-sm text-muted-foreground">{emptyDescription}</p>
|
|
59
|
-
</div>
|
|
60
|
-
</div>
|
|
61
|
-
)
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
const grouped = groupByFolder(files)
|
|
65
|
-
const folders = Object.keys(grouped).sort()
|
|
66
|
-
|
|
67
|
-
return (
|
|
68
|
-
<div className="flex-1 flex flex-col h-full min-h-0 overflow-hidden">
|
|
69
|
-
<div className="flex items-center h-8 px-6 text-[11px] font-medium text-muted-foreground/40 border-b border-black/[0.06] shrink-0 select-none">
|
|
70
|
-
<span className="flex-1 pl-8">Name</span>
|
|
71
|
-
<span className="w-20 text-right">Size</span>
|
|
72
|
-
<span className="w-10" />
|
|
73
|
-
</div>
|
|
74
|
-
|
|
75
|
-
<div className="flex-1 overflow-y-auto">
|
|
76
|
-
{loading ? (
|
|
77
|
-
<div className="flex items-center justify-center py-16">
|
|
78
|
-
<p className="text-sm text-muted-foreground/50">Loading...</p>
|
|
79
|
-
</div>
|
|
80
|
-
) : (
|
|
81
|
-
<div>
|
|
82
|
-
{folders.map((folder) =>
|
|
83
|
-
folder ? (
|
|
84
|
-
<FolderSection
|
|
85
|
-
key={folder}
|
|
86
|
-
name={folder}
|
|
87
|
-
files={grouped[folder]}
|
|
88
|
-
onOpen={onOpen}
|
|
89
|
-
onReveal={onReveal}
|
|
90
|
-
onDelete={onDelete}
|
|
91
|
-
/>
|
|
92
|
-
) : (
|
|
93
|
-
grouped[folder].map((f) => (
|
|
94
|
-
<FileRow
|
|
95
|
-
key={f.path}
|
|
96
|
-
file={f}
|
|
97
|
-
onOpen={onOpen}
|
|
98
|
-
onReveal={onReveal}
|
|
99
|
-
onDelete={onDelete}
|
|
100
|
-
/>
|
|
101
|
-
))
|
|
102
|
-
),
|
|
103
|
-
)}
|
|
104
|
-
</div>
|
|
105
|
-
)}
|
|
106
|
-
</div>
|
|
107
|
-
</div>
|
|
108
|
-
)
|
|
109
|
-
}
|
|
51
|
+
const { isDragging, dragHandlers } = useDropZone(onFilesDropped)
|
|
52
|
+
const [creatingFolder, setCreatingFolder] = useState(false)
|
|
53
|
+
const [folderDropTarget, setFolderDropTarget] = useState<string | null>(null)
|
|
54
|
+
const isEmpty = !loading && files.length === 0
|
|
55
|
+
const grouped = isEmpty ? {} : groupByFolder(files)
|
|
56
|
+
const folders = isEmpty ? [] : Object.keys(grouped).sort()
|
|
110
57
|
|
|
111
|
-
|
|
112
|
-
const grouped: Record<string, FileEntry[]> = {}
|
|
113
|
-
for (const f of files) {
|
|
114
|
-
const parts = f.path.split("/")
|
|
115
|
-
const folder = parts.length > 1 ? parts.slice(0, -1).join("/") : ""
|
|
116
|
-
;(grouped[folder] ??= []).push(f)
|
|
117
|
-
}
|
|
118
|
-
return grouped
|
|
119
|
-
}
|
|
58
|
+
const isRootTarget = isDragging && folderDropTarget === null
|
|
120
59
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
onOpen,
|
|
125
|
-
onReveal,
|
|
126
|
-
onDelete,
|
|
127
|
-
}: {
|
|
128
|
-
name: string
|
|
129
|
-
files: FileEntry[]
|
|
130
|
-
onOpen?: (file: FileEntry) => void
|
|
131
|
-
onReveal?: (file: FileEntry) => void
|
|
132
|
-
onDelete?: (file: FileEntry) => void
|
|
133
|
-
}) {
|
|
134
|
-
const [open, setOpen] = useState(true)
|
|
60
|
+
const onDragActive = useCallback((folder: string | null) => {
|
|
61
|
+
setFolderDropTarget(folder)
|
|
62
|
+
}, [])
|
|
135
63
|
|
|
136
64
|
return (
|
|
137
|
-
<div
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
<
|
|
143
|
-
className=
|
|
144
|
-
"
|
|
145
|
-
|
|
65
|
+
<div
|
|
66
|
+
className="relative flex-1 flex flex-col h-full min-h-0 overflow-hidden"
|
|
67
|
+
{...(onFilesDropped ? dragHandlers : {})}
|
|
68
|
+
>
|
|
69
|
+
{isEmpty ? (
|
|
70
|
+
<div className="flex-1 flex flex-col items-center pt-[20vh] gap-4 px-8">
|
|
71
|
+
<div className="space-y-2 text-center max-w-md">
|
|
72
|
+
<h1 className="text-2xl font-semibold text-foreground tracking-tight">
|
|
73
|
+
{emptyTitle}
|
|
74
|
+
</h1>
|
|
75
|
+
<p className="text-sm text-muted-foreground">
|
|
76
|
+
{emptyDescription}
|
|
77
|
+
</p>
|
|
78
|
+
</div>
|
|
79
|
+
{(onBrowse || onRevealWorkspace) && (
|
|
80
|
+
<div className="flex items-center gap-2">
|
|
81
|
+
{onBrowse && (
|
|
82
|
+
<Button variant="default" size="sm" onClick={onBrowse}>
|
|
83
|
+
<Upload className="size-4 mr-1.5" />
|
|
84
|
+
Browse files
|
|
85
|
+
</Button>
|
|
86
|
+
)}
|
|
87
|
+
{onRevealWorkspace && (
|
|
88
|
+
<Button variant="outline" size="sm" onClick={onRevealWorkspace}>
|
|
89
|
+
<FolderOpen className="size-4 mr-1.5" />
|
|
90
|
+
Open folder
|
|
91
|
+
</Button>
|
|
92
|
+
)}
|
|
93
|
+
</div>
|
|
146
94
|
)}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
onReveal={onReveal}
|
|
164
|
-
onDelete={onDelete}
|
|
95
|
+
{onCreateFolder && (
|
|
96
|
+
<button
|
|
97
|
+
onClick={() => setCreatingFolder(true)}
|
|
98
|
+
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
|
99
|
+
>
|
|
100
|
+
<FolderPlus className="size-4" />
|
|
101
|
+
New folder
|
|
102
|
+
</button>
|
|
103
|
+
)}
|
|
104
|
+
{creatingFolder && (
|
|
105
|
+
<NewFolderInput
|
|
106
|
+
onConfirm={(name) => {
|
|
107
|
+
onCreateFolder?.(name)
|
|
108
|
+
setCreatingFolder(false)
|
|
109
|
+
}}
|
|
110
|
+
onCancel={() => setCreatingFolder(false)}
|
|
165
111
|
/>
|
|
166
|
-
)
|
|
112
|
+
)}
|
|
167
113
|
</div>
|
|
114
|
+
) : (
|
|
115
|
+
<>
|
|
116
|
+
<div className="flex items-center h-8 px-6 text-[11px] font-medium text-muted-foreground/40 border-b border-black/[0.06] shrink-0 select-none">
|
|
117
|
+
<span className="flex-1 pl-8">Name</span>
|
|
118
|
+
<span className="w-20 text-right">Size</span>
|
|
119
|
+
<div className="w-10 flex justify-end">
|
|
120
|
+
{onCreateFolder && (
|
|
121
|
+
<button
|
|
122
|
+
onClick={() => setCreatingFolder(true)}
|
|
123
|
+
className="flex items-center justify-center w-6 h-6 rounded text-muted-foreground/30 hover:text-foreground hover:bg-black/[0.05] transition-all duration-150"
|
|
124
|
+
>
|
|
125
|
+
<FolderPlus className="size-3.5" />
|
|
126
|
+
</button>
|
|
127
|
+
)}
|
|
128
|
+
</div>
|
|
129
|
+
</div>
|
|
130
|
+
|
|
131
|
+
<div
|
|
132
|
+
className="flex-1 overflow-y-auto"
|
|
133
|
+
style={{
|
|
134
|
+
backgroundColor: isRootTarget ? "rgba(0,0,0,0.04)" : undefined,
|
|
135
|
+
transition: "background-color 150ms",
|
|
136
|
+
}}
|
|
137
|
+
>
|
|
138
|
+
{loading ? (
|
|
139
|
+
<div className="flex items-center justify-center py-16">
|
|
140
|
+
<p className="text-sm text-muted-foreground/50">Loading...</p>
|
|
141
|
+
</div>
|
|
142
|
+
) : (
|
|
143
|
+
<div>
|
|
144
|
+
{creatingFolder && (
|
|
145
|
+
<NewFolderInput
|
|
146
|
+
onConfirm={(name) => {
|
|
147
|
+
onCreateFolder?.(name)
|
|
148
|
+
setCreatingFolder(false)
|
|
149
|
+
}}
|
|
150
|
+
onCancel={() => setCreatingFolder(false)}
|
|
151
|
+
/>
|
|
152
|
+
)}
|
|
153
|
+
{folders.map((folder) =>
|
|
154
|
+
folder ? (
|
|
155
|
+
<FolderSection
|
|
156
|
+
key={folder}
|
|
157
|
+
name={folder}
|
|
158
|
+
files={grouped[folder]}
|
|
159
|
+
onOpen={onOpen}
|
|
160
|
+
onReveal={onReveal}
|
|
161
|
+
onDelete={onDelete}
|
|
162
|
+
onFilesDropped={onFilesDropped}
|
|
163
|
+
onDragActive={onDragActive}
|
|
164
|
+
/>
|
|
165
|
+
) : (
|
|
166
|
+
grouped[folder].map((f) => (
|
|
167
|
+
<FileRow
|
|
168
|
+
key={f.path}
|
|
169
|
+
file={f}
|
|
170
|
+
onOpen={onOpen}
|
|
171
|
+
onReveal={onReveal}
|
|
172
|
+
onDelete={onDelete}
|
|
173
|
+
/>
|
|
174
|
+
))
|
|
175
|
+
),
|
|
176
|
+
)}
|
|
177
|
+
</div>
|
|
178
|
+
)}
|
|
179
|
+
</div>
|
|
180
|
+
</>
|
|
168
181
|
)}
|
|
169
182
|
</div>
|
|
170
183
|
)
|
|
171
184
|
}
|
|
172
185
|
|
|
173
|
-
function
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
onOpen,
|
|
177
|
-
onReveal,
|
|
178
|
-
onDelete,
|
|
186
|
+
function NewFolderInput({
|
|
187
|
+
onConfirm,
|
|
188
|
+
onCancel,
|
|
179
189
|
}: {
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
onOpen?: (file: FileEntry) => void
|
|
183
|
-
onReveal?: (file: FileEntry) => void
|
|
184
|
-
onDelete?: (file: FileEntry) => void
|
|
190
|
+
onConfirm: (name: string) => void
|
|
191
|
+
onCancel: () => void
|
|
185
192
|
}) {
|
|
186
|
-
const
|
|
187
|
-
const
|
|
193
|
+
const [value, setValue] = useState("")
|
|
194
|
+
const inputRef = useRef<HTMLInputElement>(null)
|
|
188
195
|
|
|
189
196
|
return (
|
|
190
|
-
<
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
<DropdownMenu>
|
|
210
|
-
<DropdownMenuTrigger asChild>
|
|
211
|
-
<span
|
|
212
|
-
role="button"
|
|
213
|
-
onClick={(e) => e.stopPropagation()}
|
|
214
|
-
className="flex items-center justify-center w-6 h-6 rounded text-muted-foreground/20 hover:text-foreground hover:bg-black/[0.05] opacity-0 group-hover:opacity-100 focus:opacity-100 transition-all duration-150"
|
|
215
|
-
>
|
|
216
|
-
<MoreVertical className="size-3.5" />
|
|
217
|
-
</span>
|
|
218
|
-
</DropdownMenuTrigger>
|
|
219
|
-
<DropdownMenuContent align="end">
|
|
220
|
-
{onOpen && (
|
|
221
|
-
<DropdownMenuItem
|
|
222
|
-
onClick={(e) => {
|
|
223
|
-
e.stopPropagation()
|
|
224
|
-
onOpen(file)
|
|
225
|
-
}}
|
|
226
|
-
>
|
|
227
|
-
<ExternalLink className="size-4 mr-2" />
|
|
228
|
-
Open
|
|
229
|
-
</DropdownMenuItem>
|
|
230
|
-
)}
|
|
231
|
-
{onReveal && (
|
|
232
|
-
<DropdownMenuItem
|
|
233
|
-
onClick={(e) => {
|
|
234
|
-
e.stopPropagation()
|
|
235
|
-
onReveal(file)
|
|
236
|
-
}}
|
|
237
|
-
>
|
|
238
|
-
<FolderSearch className="size-4 mr-2" />
|
|
239
|
-
Show in Finder
|
|
240
|
-
</DropdownMenuItem>
|
|
241
|
-
)}
|
|
242
|
-
{onDelete && (
|
|
243
|
-
<DropdownMenuItem
|
|
244
|
-
onClick={(e) => {
|
|
245
|
-
e.stopPropagation()
|
|
246
|
-
onDelete(file)
|
|
247
|
-
}}
|
|
248
|
-
className="text-destructive focus:text-destructive"
|
|
249
|
-
>
|
|
250
|
-
<Trash2 className="size-4 mr-2" />
|
|
251
|
-
Delete
|
|
252
|
-
</DropdownMenuItem>
|
|
253
|
-
)}
|
|
254
|
-
</DropdownMenuContent>
|
|
255
|
-
</DropdownMenu>
|
|
256
|
-
</div>
|
|
257
|
-
)}
|
|
258
|
-
</button>
|
|
197
|
+
<div className={cn("flex items-center h-9 px-6 bg-secondary/50")}>
|
|
198
|
+
<FolderPlus className="size-4 shrink-0 text-muted-foreground/50 mr-2.5" />
|
|
199
|
+
<input
|
|
200
|
+
ref={inputRef}
|
|
201
|
+
autoFocus
|
|
202
|
+
value={value}
|
|
203
|
+
onChange={(e) => setValue(e.target.value)}
|
|
204
|
+
onKeyDown={(e) => {
|
|
205
|
+
if (e.key === "Enter" && value.trim()) onConfirm(value.trim())
|
|
206
|
+
if (e.key === "Escape") onCancel()
|
|
207
|
+
}}
|
|
208
|
+
onBlur={() => {
|
|
209
|
+
if (value.trim()) onConfirm(value.trim())
|
|
210
|
+
else onCancel()
|
|
211
|
+
}}
|
|
212
|
+
placeholder="Folder name"
|
|
213
|
+
className="flex-1 text-[13px] bg-transparent outline-none placeholder:text-muted-foreground/40"
|
|
214
|
+
/>
|
|
215
|
+
</div>
|
|
259
216
|
)
|
|
260
217
|
}
|
|
261
218
|
|
|
262
|
-
function
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
<svg className="size-4 shrink-0" viewBox="0 0 16 16" fill="none">
|
|
269
|
-
<rect width="16" height="16" rx="3" fill="#E5252A" />
|
|
270
|
-
<text
|
|
271
|
-
x="8"
|
|
272
|
-
y="11"
|
|
273
|
-
textAnchor="middle"
|
|
274
|
-
fill="white"
|
|
275
|
-
fontSize="7"
|
|
276
|
-
fontWeight="700"
|
|
277
|
-
fontFamily="system-ui, sans-serif"
|
|
278
|
-
>
|
|
279
|
-
PDF
|
|
280
|
-
</text>
|
|
281
|
-
</svg>
|
|
282
|
-
)
|
|
219
|
+
function groupByFolder(files: FileEntry[]): Record<string, FileEntry[]> {
|
|
220
|
+
const grouped: Record<string, FileEntry[]> = {}
|
|
221
|
+
for (const f of files) {
|
|
222
|
+
const parts = f.path.split("/")
|
|
223
|
+
const folder = parts.length > 1 ? parts.slice(0, -1).join("/") : ""
|
|
224
|
+
;(grouped[folder] ??= []).push(f)
|
|
283
225
|
}
|
|
284
|
-
return
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
function formatSize(bytes: number): string {
|
|
288
|
-
if (bytes < 1024) return `${bytes} B`
|
|
289
|
-
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
|
290
|
-
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
|
226
|
+
return grouped
|
|
291
227
|
}
|
package/src/index.ts
CHANGED