@deck-ui/workspace 0.1.2 → 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/LICENSE +21 -0
- package/package.json +2 -2
- package/src/drop-zone.tsx +92 -0
- package/src/file-row.tsx +208 -0
- package/src/files-browser.tsx +152 -238
- package/src/index.ts +3 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ja-818
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
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 } from "@deck-ui/core"
|
|
8
|
+
import { FolderPlus } 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,10 @@ 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
|
|
36
28
|
/** Title for empty state */
|
|
37
29
|
emptyTitle?: string
|
|
38
30
|
/** Description for empty state */
|
|
@@ -45,247 +37,169 @@ export function FilesBrowser({
|
|
|
45
37
|
onOpen,
|
|
46
38
|
onReveal,
|
|
47
39
|
onDelete,
|
|
40
|
+
onFilesDropped,
|
|
41
|
+
onCreateFolder,
|
|
48
42
|
emptyTitle = "Your work shows up here",
|
|
49
43
|
emptyDescription = "When agents create files, they'll appear here for you to open and review.",
|
|
50
44
|
}: 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
|
-
}
|
|
45
|
+
const { isDragging, dragHandlers } = useDropZone(onFilesDropped)
|
|
46
|
+
const [creatingFolder, setCreatingFolder] = useState(false)
|
|
47
|
+
const [folderDropTarget, setFolderDropTarget] = useState<string | null>(null)
|
|
48
|
+
const isEmpty = !loading && files.length === 0
|
|
49
|
+
const grouped = isEmpty ? {} : groupByFolder(files)
|
|
50
|
+
const folders = isEmpty ? [] : Object.keys(grouped).sort()
|
|
63
51
|
|
|
64
|
-
const
|
|
65
|
-
const folders = Object.keys(grouped).sort()
|
|
52
|
+
const isRootTarget = isDragging && folderDropTarget === null
|
|
66
53
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
}
|
|
110
|
-
|
|
111
|
-
function groupByFolder(files: FileEntry[]): Record<string, FileEntry[]> {
|
|
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
|
-
}
|
|
120
|
-
|
|
121
|
-
function FolderSection({
|
|
122
|
-
name,
|
|
123
|
-
files,
|
|
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)
|
|
54
|
+
const onDragActive = useCallback((folder: string | null) => {
|
|
55
|
+
setFolderDropTarget(folder)
|
|
56
|
+
}, [])
|
|
135
57
|
|
|
136
58
|
return (
|
|
137
|
-
<div
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
<
|
|
143
|
-
className=
|
|
144
|
-
"
|
|
145
|
-
|
|
59
|
+
<div
|
|
60
|
+
className="relative flex-1 flex flex-col h-full min-h-0 overflow-hidden"
|
|
61
|
+
{...(onFilesDropped ? dragHandlers : {})}
|
|
62
|
+
>
|
|
63
|
+
{isEmpty ? (
|
|
64
|
+
<div className="flex-1 flex flex-col items-center pt-[20vh] gap-4 px-8">
|
|
65
|
+
<div className="space-y-2 text-center max-w-md">
|
|
66
|
+
<h1 className="text-2xl font-semibold text-foreground tracking-tight">
|
|
67
|
+
{emptyTitle}
|
|
68
|
+
</h1>
|
|
69
|
+
<p className="text-sm text-muted-foreground">
|
|
70
|
+
{emptyDescription}
|
|
71
|
+
</p>
|
|
72
|
+
</div>
|
|
73
|
+
{onCreateFolder && (
|
|
74
|
+
<button
|
|
75
|
+
onClick={() => setCreatingFolder(true)}
|
|
76
|
+
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
|
77
|
+
>
|
|
78
|
+
<FolderPlus className="size-4" />
|
|
79
|
+
New folder
|
|
80
|
+
</button>
|
|
146
81
|
)}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
</button>
|
|
155
|
-
{open && (
|
|
156
|
-
<div>
|
|
157
|
-
{files.map((f) => (
|
|
158
|
-
<FileRow
|
|
159
|
-
key={f.path}
|
|
160
|
-
file={f}
|
|
161
|
-
indent
|
|
162
|
-
onOpen={onOpen}
|
|
163
|
-
onReveal={onReveal}
|
|
164
|
-
onDelete={onDelete}
|
|
82
|
+
{creatingFolder && (
|
|
83
|
+
<NewFolderInput
|
|
84
|
+
onConfirm={(name) => {
|
|
85
|
+
onCreateFolder?.(name)
|
|
86
|
+
setCreatingFolder(false)
|
|
87
|
+
}}
|
|
88
|
+
onCancel={() => setCreatingFolder(false)}
|
|
165
89
|
/>
|
|
166
|
-
)
|
|
90
|
+
)}
|
|
167
91
|
</div>
|
|
92
|
+
) : (
|
|
93
|
+
<>
|
|
94
|
+
<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">
|
|
95
|
+
<span className="flex-1 pl-8">Name</span>
|
|
96
|
+
<span className="w-20 text-right">Size</span>
|
|
97
|
+
<div className="w-10 flex justify-end">
|
|
98
|
+
{onCreateFolder && (
|
|
99
|
+
<button
|
|
100
|
+
onClick={() => setCreatingFolder(true)}
|
|
101
|
+
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"
|
|
102
|
+
>
|
|
103
|
+
<FolderPlus className="size-3.5" />
|
|
104
|
+
</button>
|
|
105
|
+
)}
|
|
106
|
+
</div>
|
|
107
|
+
</div>
|
|
108
|
+
|
|
109
|
+
<div
|
|
110
|
+
className="flex-1 overflow-y-auto"
|
|
111
|
+
style={{
|
|
112
|
+
backgroundColor: isRootTarget ? "rgba(0,0,0,0.04)" : undefined,
|
|
113
|
+
transition: "background-color 150ms",
|
|
114
|
+
}}
|
|
115
|
+
>
|
|
116
|
+
{loading ? (
|
|
117
|
+
<div className="flex items-center justify-center py-16">
|
|
118
|
+
<p className="text-sm text-muted-foreground/50">Loading...</p>
|
|
119
|
+
</div>
|
|
120
|
+
) : (
|
|
121
|
+
<div>
|
|
122
|
+
{creatingFolder && (
|
|
123
|
+
<NewFolderInput
|
|
124
|
+
onConfirm={(name) => {
|
|
125
|
+
onCreateFolder?.(name)
|
|
126
|
+
setCreatingFolder(false)
|
|
127
|
+
}}
|
|
128
|
+
onCancel={() => setCreatingFolder(false)}
|
|
129
|
+
/>
|
|
130
|
+
)}
|
|
131
|
+
{folders.map((folder) =>
|
|
132
|
+
folder ? (
|
|
133
|
+
<FolderSection
|
|
134
|
+
key={folder}
|
|
135
|
+
name={folder}
|
|
136
|
+
files={grouped[folder]}
|
|
137
|
+
onOpen={onOpen}
|
|
138
|
+
onReveal={onReveal}
|
|
139
|
+
onDelete={onDelete}
|
|
140
|
+
onFilesDropped={onFilesDropped}
|
|
141
|
+
onDragActive={onDragActive}
|
|
142
|
+
/>
|
|
143
|
+
) : (
|
|
144
|
+
grouped[folder].map((f) => (
|
|
145
|
+
<FileRow
|
|
146
|
+
key={f.path}
|
|
147
|
+
file={f}
|
|
148
|
+
onOpen={onOpen}
|
|
149
|
+
onReveal={onReveal}
|
|
150
|
+
onDelete={onDelete}
|
|
151
|
+
/>
|
|
152
|
+
))
|
|
153
|
+
),
|
|
154
|
+
)}
|
|
155
|
+
</div>
|
|
156
|
+
)}
|
|
157
|
+
</div>
|
|
158
|
+
</>
|
|
168
159
|
)}
|
|
169
160
|
</div>
|
|
170
161
|
)
|
|
171
162
|
}
|
|
172
163
|
|
|
173
|
-
function
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
onOpen,
|
|
177
|
-
onReveal,
|
|
178
|
-
onDelete,
|
|
164
|
+
function NewFolderInput({
|
|
165
|
+
onConfirm,
|
|
166
|
+
onCancel,
|
|
179
167
|
}: {
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
onOpen?: (file: FileEntry) => void
|
|
183
|
-
onReveal?: (file: FileEntry) => void
|
|
184
|
-
onDelete?: (file: FileEntry) => void
|
|
168
|
+
onConfirm: (name: string) => void
|
|
169
|
+
onCancel: () => void
|
|
185
170
|
}) {
|
|
186
|
-
const
|
|
187
|
-
const
|
|
171
|
+
const [value, setValue] = useState("")
|
|
172
|
+
const inputRef = useRef<HTMLInputElement>(null)
|
|
188
173
|
|
|
189
174
|
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>
|
|
175
|
+
<div className={cn("flex items-center h-9 px-6 bg-secondary/50")}>
|
|
176
|
+
<FolderPlus className="size-4 shrink-0 text-muted-foreground/50 mr-2.5" />
|
|
177
|
+
<input
|
|
178
|
+
ref={inputRef}
|
|
179
|
+
autoFocus
|
|
180
|
+
value={value}
|
|
181
|
+
onChange={(e) => setValue(e.target.value)}
|
|
182
|
+
onKeyDown={(e) => {
|
|
183
|
+
if (e.key === "Enter" && value.trim()) onConfirm(value.trim())
|
|
184
|
+
if (e.key === "Escape") onCancel()
|
|
185
|
+
}}
|
|
186
|
+
onBlur={() => {
|
|
187
|
+
if (value.trim()) onConfirm(value.trim())
|
|
188
|
+
else onCancel()
|
|
189
|
+
}}
|
|
190
|
+
placeholder="Folder name"
|
|
191
|
+
className="flex-1 text-[13px] bg-transparent outline-none placeholder:text-muted-foreground/40"
|
|
192
|
+
/>
|
|
193
|
+
</div>
|
|
259
194
|
)
|
|
260
195
|
}
|
|
261
196
|
|
|
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
|
-
)
|
|
197
|
+
function groupByFolder(files: FileEntry[]): Record<string, FileEntry[]> {
|
|
198
|
+
const grouped: Record<string, FileEntry[]> = {}
|
|
199
|
+
for (const f of files) {
|
|
200
|
+
const parts = f.path.split("/")
|
|
201
|
+
const folder = parts.length > 1 ? parts.slice(0, -1).join("/") : ""
|
|
202
|
+
;(grouped[folder] ??= []).push(f)
|
|
283
203
|
}
|
|
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`
|
|
204
|
+
return grouped
|
|
291
205
|
}
|
package/src/index.ts
CHANGED