@hienlh/ppm 0.13.15 → 0.13.17

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.
Files changed (56) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/CLAUDE.md +5 -0
  3. package/assets/skills/ppm/SKILL.md +1 -1
  4. package/assets/skills/ppm/references/http-api.md +1 -1
  5. package/bun.lock +2135 -0
  6. package/bunfig.toml +2 -0
  7. package/dist/web/assets/{audio-preview-YOG6Biao.js → audio-preview-CEYHVcPO.js} +1 -1
  8. package/dist/web/assets/{chat-tab-DbdDJuLu.js → chat-tab-DgKpBVjE.js} +3 -3
  9. package/dist/web/assets/code-editor-Cq2IOaV8.js +8 -0
  10. package/dist/web/assets/{conflict-editor-DnGfriL5.js → conflict-editor-C3l0f_1F.js} +1 -1
  11. package/dist/web/assets/{database-viewer-AodppoTs.js → database-viewer-Bsj5g3RI.js} +1 -1
  12. package/dist/web/assets/{diff-viewer-DykLUwna.js → diff-viewer-LNzXRYQX.js} +1 -1
  13. package/dist/web/assets/{extension-webview-Bck7QuaB.js → extension-webview-ClSDQWq_.js} +1 -1
  14. package/dist/web/assets/file-store-DOxcU_7s.js +1 -0
  15. package/dist/web/assets/{glide-data-grid-BVt0mwcA.js → glide-data-grid-rA_8qdHA.js} +1 -1
  16. package/dist/web/assets/{image-preview-DaSmrIvY.js → image-preview-BEC5d-NV.js} +1 -1
  17. package/dist/web/assets/index-VDmxXycP.js +27 -0
  18. package/dist/web/assets/index-nC9UURj4.css +2 -0
  19. package/dist/web/assets/keybindings-store-CNsaPqyF.js +1 -0
  20. package/dist/web/assets/{markdown-renderer-B1me_hz2.js → markdown-renderer-CPVvj7aP.js} +1 -1
  21. package/dist/web/assets/{pdf-preview-Dci7TIL1.js → pdf-preview-Dn0-iiir.js} +1 -1
  22. package/dist/web/assets/{port-forwarding-tab-BeM40G-J.js → port-forwarding-tab-BTScoTXw.js} +1 -1
  23. package/dist/web/assets/{postgres-viewer-CGVBOwA9.js → postgres-viewer-zrm-uI0Y.js} +1 -1
  24. package/dist/web/assets/{settings-tab-CYS8VfNl.js → settings-tab-D3_bKOG3.js} +1 -1
  25. package/dist/web/assets/{sql-query-editor-DstPySPF.js → sql-query-editor-CGIWCq6O.js} +1 -1
  26. package/dist/web/assets/{sqlite-viewer-SUGEk_G1.js → sqlite-viewer-Bvd2SXu-.js} +1 -1
  27. package/dist/web/assets/{terminal-tab-CJvjF79J.js → terminal-tab-BwdYHHa4.js} +1 -1
  28. package/dist/web/assets/{video-preview-gJSKmPQr.js → video-preview-onuXf7EW.js} +1 -1
  29. package/dist/web/index.html +3 -3
  30. package/dist/web/sw.js +1 -1
  31. package/package.json +1 -1
  32. package/src/index.ts +0 -0
  33. package/src/server/routes/files.ts +15 -0
  34. package/src/services/file.service.ts +15 -0
  35. package/src/web/components/editor/editor-breadcrumb.tsx +88 -36
  36. package/src/web/components/explorer/file-actions.tsx +12 -129
  37. package/src/web/components/explorer/file-icon-map.ts +69 -0
  38. package/src/web/components/explorer/file-tree.tsx +177 -362
  39. package/src/web/components/explorer/inline-tree-input.tsx +120 -0
  40. package/src/web/components/explorer/tree-node-context-menu.tsx +97 -0
  41. package/src/web/components/explorer/tree-node.tsx +343 -0
  42. package/src/web/components/explorer/use-file-upload-drag.ts +84 -0
  43. package/src/web/components/explorer/use-tree-keyboard-nav.ts +126 -0
  44. package/src/web/components/layout/mobile-nav.tsx +73 -84
  45. package/src/web/components/layout/project-bottom-sheet.tsx +61 -82
  46. package/src/web/components/ui/adaptive-context-menu.tsx +245 -0
  47. package/src/web/components/ui/mobile-bottom-sheet.tsx +155 -0
  48. package/src/web/hooks/use-is-mobile.ts +28 -0
  49. package/src/web/hooks/use-swipe-to-dismiss.ts +51 -0
  50. package/src/web/stores/file-store.ts +74 -3
  51. package/src/web/stores/git-status-store.ts +87 -2
  52. package/dist/web/assets/code-editor-C4nuAsy6.js +0 -8
  53. package/dist/web/assets/file-store-4BpOJthN.js +0 -1
  54. package/dist/web/assets/index-CSK33ACc.css +0 -2
  55. package/dist/web/assets/index-gZKF1YKy.js +0 -27
  56. package/dist/web/assets/keybindings-store-DBKLTPrk.js +0 -1
@@ -12,6 +12,7 @@ import {
12
12
  import { useFileStore, type FileNode } from "@/stores/file-store";
13
13
  import { useShallow } from "zustand/react/shallow";
14
14
  import { useTabStore } from "@/stores/tab-store";
15
+ import { useProjectStore } from "@/stores/project-store";
15
16
  import { basename } from "@/lib/utils";
16
17
 
17
18
  const ICON_MAP: Record<string, React.ComponentType<{ className?: string }>> = {
@@ -34,11 +35,14 @@ interface BreadcrumbSegment {
34
35
  fullPath: string;
35
36
  node: FileNode | null;
36
37
  siblings: FileNode[];
38
+ /** Folder path whose children are the siblings (empty string = root) */
39
+ parentPath: string;
37
40
  }
38
41
 
39
42
  function walkTree(tree: FileNode[], segments: string[]): BreadcrumbSegment[] {
40
43
  const result: BreadcrumbSegment[] = [];
41
44
  let current: FileNode[] = tree;
45
+ let parentPath = "";
42
46
 
43
47
  for (let i = 0; i < segments.length; i++) {
44
48
  const seg = segments[i]!;
@@ -49,17 +53,20 @@ function walkTree(tree: FileNode[], segments: string[]): BreadcrumbSegment[] {
49
53
  fullPath,
50
54
  node: match ?? null,
51
55
  siblings: current,
56
+ parentPath,
52
57
  });
53
58
  if (match?.children) {
59
+ parentPath = match.path;
54
60
  current = match.children;
55
61
  } else {
56
- // Remaining segments have no tree data add as plain
62
+ // Remaining segments parent children not loaded yet
57
63
  for (let j = i + 1; j < segments.length; j++) {
58
64
  result.push({
59
65
  name: segments[j]!,
60
66
  fullPath: segments.slice(0, j + 1).join("/"),
61
67
  node: null,
62
68
  siblings: [],
69
+ parentPath: segments.slice(0, j).join("/"),
63
70
  });
64
71
  }
65
72
  break;
@@ -85,11 +92,23 @@ interface EditorBreadcrumbProps {
85
92
  export function EditorBreadcrumb({ filePath, projectName, tabId, className }: EditorBreadcrumbProps) {
86
93
  const tree = useFileStore((s) => s.tree);
87
94
  const { updateTab, openTab } = useTabStore(useShallow((s) => ({ updateTab: s.updateTab, openTab: s.openTab })));
95
+ const projectPath = useProjectStore((s) => s.projects.find((p) => p.name === projectName)?.path ?? "");
88
96
  const scrollRef = useRef<HTMLDivElement>(null);
89
97
 
98
+ // Strip project root prefix so segments align with the relative-path file tree
99
+ const { prefixParts, relativePath } = useMemo(() => {
100
+ const norm = filePath.startsWith("/") ? filePath.slice(1) : filePath;
101
+ const normRoot = projectPath.startsWith("/") ? projectPath.slice(1) : projectPath;
102
+ if (normRoot && norm.startsWith(normRoot + "/")) {
103
+ const rel = norm.slice(normRoot.length + 1);
104
+ return { prefixParts: normRoot.split("/"), relativePath: rel };
105
+ }
106
+ return { prefixParts: [] as string[], relativePath: norm };
107
+ }, [filePath, projectPath]);
108
+
90
109
  const segments = useMemo(
91
- () => walkTree(tree, filePath.split("/").filter(Boolean)),
92
- [tree, filePath],
110
+ () => walkTree(tree, relativePath.split("/").filter(Boolean)),
111
+ [tree, relativePath],
93
112
  );
94
113
 
95
114
  // Auto-scroll to rightmost segment
@@ -110,19 +129,21 @@ export function EditorBreadcrumb({ filePath, projectName, tabId, className }: Ed
110
129
 
111
130
  return (
112
131
  <div ref={scrollRef} className={className}>
132
+ {prefixParts.map((part, i) => (
133
+ <div key={`prefix-${i}`} className="flex items-center shrink-0">
134
+ {i > 0 && <ChevronRight className="size-3 text-muted-foreground shrink-0 mx-0.5" />}
135
+ <span className="text-xs text-muted-foreground px-1 py-0.5">{part}</span>
136
+ </div>
137
+ ))}
113
138
  {segments.map((seg, i) => (
114
139
  <div key={seg.fullPath} className="flex items-center shrink-0">
115
- {i > 0 && <ChevronRight className="size-3 text-muted-foreground shrink-0 mx-0.5" />}
116
- {seg.siblings.length > 0 ? (
117
- <SegmentDropdown
118
- segment={seg}
119
- isLast={i === segments.length - 1}
120
- projectName={projectName}
121
- onFileClick={handleFileClick}
122
- />
123
- ) : (
124
- <span className="text-xs text-muted-foreground px-1 py-0.5">{seg.name}</span>
125
- )}
140
+ {(i > 0 || prefixParts.length > 0) && <ChevronRight className="size-3 text-muted-foreground shrink-0 mx-0.5" />}
141
+ <SegmentDropdown
142
+ segment={seg}
143
+ isLast={i === segments.length - 1}
144
+ projectName={projectName}
145
+ onFileClick={handleFileClick}
146
+ />
126
147
  </div>
127
148
  ))}
128
149
  </div>
@@ -137,10 +158,19 @@ interface SegmentDropdownProps {
137
158
  }
138
159
 
139
160
  function SegmentDropdown({ segment, isLast, projectName, onFileClick }: SegmentDropdownProps) {
161
+ const loadChildren = useFileStore((s) => s.loadChildren);
162
+ const loadedPaths = useFileStore((s) => s.loadedPaths);
140
163
  const sorted = useMemo(() => sortNodes(segment.siblings), [segment.siblings]);
164
+ const isLoaded = loadedPaths.has(segment.parentPath);
165
+
166
+ function handleOpenChange(open: boolean) {
167
+ if (open && !isLoaded) {
168
+ loadChildren(projectName, segment.parentPath);
169
+ }
170
+ }
141
171
 
142
172
  return (
143
- <DropdownMenu>
173
+ <DropdownMenu onOpenChange={handleOpenChange}>
144
174
  <DropdownMenuTrigger asChild>
145
175
  <button
146
176
  type="button"
@@ -152,15 +182,21 @@ function SegmentDropdown({ segment, isLast, projectName, onFileClick }: SegmentD
152
182
  </button>
153
183
  </DropdownMenuTrigger>
154
184
  <DropdownMenuContent align="start" className="max-h-[300px] p-1">
155
- {sorted.map((node) => (
156
- <NodeMenuItem
157
- key={node.path}
158
- node={node}
159
- projectName={projectName}
160
- activePath={segment.fullPath}
161
- onFileClick={onFileClick}
162
- />
163
- ))}
185
+ {sorted.length === 0 ? (
186
+ <DropdownMenuItem disabled className="text-xs text-muted-foreground">
187
+ Loading…
188
+ </DropdownMenuItem>
189
+ ) : (
190
+ sorted.map((node) => (
191
+ <NodeMenuItem
192
+ key={node.path}
193
+ node={node}
194
+ projectName={projectName}
195
+ activePath={segment.fullPath}
196
+ onFileClick={onFileClick}
197
+ />
198
+ ))
199
+ )}
164
200
  </DropdownMenuContent>
165
201
  </DropdownMenu>
166
202
  );
@@ -176,24 +212,41 @@ interface NodeMenuItemProps {
176
212
  function NodeMenuItem({ node, projectName, activePath, onFileClick }: NodeMenuItemProps) {
177
213
  const Icon = getIcon(node.name, node.type === "directory");
178
214
  const isActive = node.path === activePath;
215
+ const loadChildren = useFileStore((s) => s.loadChildren);
216
+ const loadedPaths = useFileStore((s) => s.loadedPaths);
217
+
218
+ if (node.type === "directory") {
219
+ const children = node.children ?? [];
220
+ const isLoaded = loadedPaths.has(node.path);
221
+
222
+ function handleSubOpen(open: boolean) {
223
+ if (open && !isLoaded) {
224
+ loadChildren(projectName, node.path);
225
+ }
226
+ }
179
227
 
180
- if (node.type === "directory" && node.children && node.children.length > 0) {
181
228
  return (
182
- <DropdownMenuSub>
229
+ <DropdownMenuSub onOpenChange={handleSubOpen}>
183
230
  <DropdownMenuSubTrigger className={`text-xs gap-1.5 ${isActive ? "bg-muted" : ""}`}>
184
231
  <Icon className="size-3.5 shrink-0 text-muted-foreground" />
185
232
  <span className="truncate">{node.name}</span>
186
233
  </DropdownMenuSubTrigger>
187
234
  <DropdownMenuSubContent className="max-h-[300px] overflow-y-auto p-1">
188
- {sortNodes(node.children).map((child) => (
189
- <NodeMenuItem
190
- key={child.path}
191
- node={child}
192
- projectName={projectName}
193
- activePath={activePath}
194
- onFileClick={onFileClick}
195
- />
196
- ))}
235
+ {children.length === 0 ? (
236
+ <DropdownMenuItem disabled className="text-xs text-muted-foreground">
237
+ Loading…
238
+ </DropdownMenuItem>
239
+ ) : (
240
+ sortNodes(children).map((child) => (
241
+ <NodeMenuItem
242
+ key={child.path}
243
+ node={child}
244
+ projectName={projectName}
245
+ activePath={activePath}
246
+ onFileClick={onFileClick}
247
+ />
248
+ ))
249
+ )}
197
250
  </DropdownMenuSubContent>
198
251
  </DropdownMenuSub>
199
252
  );
@@ -206,7 +259,6 @@ function NodeMenuItem({ node, projectName, activePath, onFileClick }: NodeMenuIt
206
259
  // onSelect doesn't give MouseEvent, use click handler for Ctrl detection
207
260
  }}
208
261
  onClick={(e) => {
209
- if (node.type === "directory") return;
210
262
  onFileClick(node.path, e);
211
263
  }}
212
264
  >
@@ -1,4 +1,8 @@
1
- import { useState, useEffect, useRef } from "react";
1
+ /**
2
+ * FileActions — delete confirmation dialog.
3
+ * Create and rename are handled inline by InlineTreeInput.
4
+ */
5
+ import { useState } from "react";
2
6
  import { api, projectUrl } from "@/lib/api-client";
3
7
  import type { FileNode } from "@/stores/file-store";
4
8
  import {
@@ -10,10 +14,9 @@ import {
10
14
  DialogFooter,
11
15
  } from "@/components/ui/dialog";
12
16
  import { Button } from "@/components/ui/button";
13
- import { Input } from "@/components/ui/input";
14
17
 
15
18
  interface FileActionsProps {
16
- action: string;
19
+ action: "delete";
17
20
  node: FileNode;
18
21
  projectName: string;
19
22
  onClose: () => void;
@@ -21,73 +24,13 @@ interface FileActionsProps {
21
24
  }
22
25
 
23
26
  export function FileActions({
24
- action,
25
27
  node,
26
28
  projectName,
27
29
  onClose,
28
30
  onRefresh,
29
31
  }: FileActionsProps) {
30
- const [name, setName] = useState("");
31
32
  const [loading, setLoading] = useState(false);
32
33
  const [error, setError] = useState<string | null>(null);
33
- const inputRef = useRef<HTMLInputElement>(null);
34
-
35
- useEffect(() => {
36
- if (action === "rename") {
37
- setName(node.name);
38
- } else {
39
- setName("");
40
- }
41
- }, [action, node.name]);
42
-
43
- useEffect(() => {
44
- // Focus input after dialog mounts
45
- const timer = setTimeout(() => inputRef.current?.focus(), 100);
46
- return () => clearTimeout(timer);
47
- }, []);
48
-
49
- async function handleCreate(type: "file" | "directory") {
50
- if (!name.trim()) return;
51
- setLoading(true);
52
- setError(null);
53
- try {
54
- const parentPath = node.type === "directory" ? node.path : node.path.split("/").slice(0, -1).join("/");
55
- const fullPath = parentPath ? `${parentPath}/${name.trim()}` : name.trim();
56
- await api.post(`${projectUrl(projectName)}/files/create`, {
57
- path: fullPath,
58
- type,
59
- });
60
- onRefresh();
61
- onClose();
62
- } catch (err) {
63
- setError(err instanceof Error ? err.message : "Failed to create");
64
- } finally {
65
- setLoading(false);
66
- }
67
- }
68
-
69
- async function handleRename() {
70
- if (!name.trim() || name.trim() === node.name) {
71
- onClose();
72
- return;
73
- }
74
- setLoading(true);
75
- setError(null);
76
- try {
77
- const parentPath = node.path.split("/").slice(0, -1).join("/");
78
- const newPath = parentPath ? `${parentPath}/${name.trim()}` : name.trim();
79
- await api.post(`${projectUrl(projectName)}/files/rename`, {
80
- oldPath: node.path,
81
- newPath,
82
- });
83
- onRefresh();
84
- onClose();
85
- } catch (err) {
86
- setError(err instanceof Error ? err.message : "Failed to rename");
87
- } finally {
88
- setLoading(false);
89
- }
90
- }
91
34
 
92
35
  async function handleDelete() {
93
36
  setLoading(true);
@@ -105,84 +48,24 @@ export function FileActions({
105
48
  }
106
49
  }
107
50
 
108
- function handleKeyDown(e: React.KeyboardEvent) {
109
- if (e.key === "Enter") {
110
- if (action === "new-file") handleCreate("file");
111
- else if (action === "new-folder") handleCreate("directory");
112
- else if (action === "rename") handleRename();
113
- }
114
- if (e.key === "Escape") onClose();
115
- }
116
-
117
- if (action === "delete") {
118
- return (
119
- <Dialog open onOpenChange={(open) => !open && onClose()}>
120
- <DialogContent>
121
- <DialogHeader>
122
- <DialogTitle>Delete {node.type === "directory" ? "Folder" : "File"}</DialogTitle>
123
- <DialogDescription>
124
- Are you sure you want to delete{" "}
125
- <span className="font-mono font-semibold">{node.name}</span>?
126
- {node.type === "directory" && " This will delete all contents."}
127
- </DialogDescription>
128
- </DialogHeader>
129
- {error && <p className="text-sm text-error">{error}</p>}
130
- <DialogFooter>
131
- <Button variant="outline" onClick={onClose} disabled={loading}>
132
- Cancel
133
- </Button>
134
- <Button variant="destructive" onClick={handleDelete} disabled={loading}>
135
- {loading ? "Deleting..." : "Delete"}
136
- </Button>
137
- </DialogFooter>
138
- </DialogContent>
139
- </Dialog>
140
- );
141
- }
142
-
143
- const title =
144
- action === "new-file"
145
- ? "New File"
146
- : action === "new-folder"
147
- ? "New Folder"
148
- : "Rename";
149
-
150
- const placeholder =
151
- action === "rename" ? node.name : action === "new-file" ? "filename.ts" : "folder-name";
152
-
153
51
  return (
154
52
  <Dialog open onOpenChange={(open) => !open && onClose()}>
155
53
  <DialogContent>
156
54
  <DialogHeader>
157
- <DialogTitle>{title}</DialogTitle>
55
+ <DialogTitle>Delete {node.type === "directory" ? "Folder" : "File"}</DialogTitle>
158
56
  <DialogDescription>
159
- {action === "rename"
160
- ? `Rename "${node.name}" to:`
161
- : `Create in ${node.type === "directory" ? node.path || "/" : node.path.split("/").slice(0, -1).join("/") || "/"}`}
57
+ Are you sure you want to delete{" "}
58
+ <span className="font-mono font-semibold">{node.name}</span>?
59
+ {node.type === "directory" && " This will delete all contents."}
162
60
  </DialogDescription>
163
61
  </DialogHeader>
164
- <Input
165
- ref={inputRef}
166
- value={name}
167
- onChange={(e) => setName(e.target.value)}
168
- onKeyDown={handleKeyDown}
169
- placeholder={placeholder}
170
- disabled={loading}
171
- />
172
62
  {error && <p className="text-sm text-error">{error}</p>}
173
63
  <DialogFooter>
174
64
  <Button variant="outline" onClick={onClose} disabled={loading}>
175
65
  Cancel
176
66
  </Button>
177
- <Button
178
- onClick={() => {
179
- if (action === "new-file") handleCreate("file");
180
- else if (action === "new-folder") handleCreate("directory");
181
- else handleRename();
182
- }}
183
- disabled={loading || !name.trim()}
184
- >
185
- {loading ? "Saving..." : action === "rename" ? "Rename" : "Create"}
67
+ <Button variant="destructive" onClick={handleDelete} disabled={loading}>
68
+ {loading ? "Deleting..." : "Delete"}
186
69
  </Button>
187
70
  </DialogFooter>
188
71
  </DialogContent>
@@ -0,0 +1,69 @@
1
+ /**
2
+ * File extension → icon + color mapping for the file tree explorer.
3
+ * Extracted from file-tree.tsx for modularity.
4
+ */
5
+ import {
6
+ File,
7
+ FileCode,
8
+ FileJson,
9
+ FileText,
10
+ FileType,
11
+ FileImage,
12
+ FileVideo,
13
+ FileAudio,
14
+ FileSpreadsheet,
15
+ FileArchive,
16
+ Database,
17
+ } from "lucide-react";
18
+
19
+ export type FileIconInfo = { icon: React.ComponentType<{ className?: string }>; color?: string };
20
+
21
+ const FILE_ICON_MAP: Record<string, FileIconInfo> = {
22
+ // Code
23
+ ts: { icon: FileCode, color: "text-blue-400" }, tsx: { icon: FileCode, color: "text-blue-400" },
24
+ js: { icon: FileCode, color: "text-yellow-400" }, jsx: { icon: FileCode, color: "text-yellow-400" },
25
+ py: { icon: FileCode, color: "text-green-400" }, rs: { icon: FileCode, color: "text-orange-400" },
26
+ go: { icon: FileCode, color: "text-cyan-400" }, c: { icon: FileCode, color: "text-blue-300" },
27
+ cpp: { icon: FileCode, color: "text-blue-300" }, java: { icon: FileCode, color: "text-red-400" },
28
+ rb: { icon: FileCode, color: "text-red-400" }, php: { icon: FileCode, color: "text-purple-400" },
29
+ swift: { icon: FileCode, color: "text-orange-400" }, kt: { icon: FileCode, color: "text-purple-400" },
30
+ dart: { icon: FileCode, color: "text-cyan-400" }, sh: { icon: FileCode, color: "text-green-300" },
31
+ html: { icon: FileCode, color: "text-orange-400" }, css: { icon: FileCode, color: "text-blue-400" },
32
+ scss: { icon: FileCode, color: "text-pink-400" },
33
+ // Data
34
+ json: { icon: FileJson, color: "text-yellow-400" },
35
+ yaml: { icon: FileType, color: "text-orange-300" }, yml: { icon: FileType, color: "text-orange-300" },
36
+ toml: { icon: FileType, color: "text-orange-300" }, ini: { icon: FileType, color: "text-orange-300" },
37
+ env: { icon: FileType, color: "text-yellow-300" },
38
+ csv: { icon: FileSpreadsheet, color: "text-green-400" },
39
+ xls: { icon: FileSpreadsheet, color: "text-green-400" }, xlsx: { icon: FileSpreadsheet, color: "text-green-400" },
40
+ // Text/Docs
41
+ md: { icon: FileText, color: "text-text-secondary" }, txt: { icon: FileText, color: "text-text-secondary" },
42
+ log: { icon: FileText, color: "text-text-subtle" }, pdf: { icon: FileText, color: "text-red-400" },
43
+ // Images
44
+ png: { icon: FileImage, color: "text-green-400" }, jpg: { icon: FileImage, color: "text-green-400" },
45
+ jpeg: { icon: FileImage, color: "text-green-400" }, gif: { icon: FileImage, color: "text-green-400" },
46
+ svg: { icon: FileImage, color: "text-yellow-400" }, webp: { icon: FileImage, color: "text-green-400" },
47
+ ico: { icon: FileImage, color: "text-green-400" }, bmp: { icon: FileImage, color: "text-green-400" },
48
+ // Video
49
+ mp4: { icon: FileVideo, color: "text-purple-400" }, webm: { icon: FileVideo, color: "text-purple-400" },
50
+ mov: { icon: FileVideo, color: "text-purple-400" }, avi: { icon: FileVideo, color: "text-purple-400" },
51
+ mkv: { icon: FileVideo, color: "text-purple-400" },
52
+ // Audio
53
+ mp3: { icon: FileAudio, color: "text-pink-400" }, wav: { icon: FileAudio, color: "text-pink-400" },
54
+ ogg: { icon: FileAudio, color: "text-pink-400" }, flac: { icon: FileAudio, color: "text-pink-400" },
55
+ // Database
56
+ db: { icon: Database, color: "text-amber-400" }, sqlite: { icon: Database, color: "text-amber-400" },
57
+ sqlite3: { icon: Database, color: "text-amber-400" }, sql: { icon: Database, color: "text-amber-400" },
58
+ // Archives
59
+ zip: { icon: FileArchive, color: "text-amber-300" }, tar: { icon: FileArchive, color: "text-amber-300" },
60
+ gz: { icon: FileArchive, color: "text-amber-300" }, rar: { icon: FileArchive, color: "text-amber-300" },
61
+ "7z": { icon: FileArchive, color: "text-amber-300" },
62
+ };
63
+
64
+ const DEFAULT_FILE_ICON: FileIconInfo = { icon: File };
65
+
66
+ export function getFileIcon(name: string): FileIconInfo {
67
+ const ext = name.split(".").pop()?.toLowerCase() ?? "";
68
+ return FILE_ICON_MAP[ext] ?? DEFAULT_FILE_ICON;
69
+ }