@hienlh/ppm 0.6.5 → 0.6.7

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 (44) hide show
  1. package/CHANGELOG.md +22 -5
  2. package/dist/web/assets/chat-tab-CWhxhPKH.js +7 -0
  3. package/dist/web/assets/{code-editor-CVMeIylx.js → code-editor-BUg7alP6.js} +1 -1
  4. package/dist/web/assets/database-viewer-CAgZOkZc.js +1 -0
  5. package/dist/web/assets/{diff-viewer-B1vnegRS.js → diff-viewer-DVvY1aFb.js} +1 -1
  6. package/dist/web/assets/{git-graph-Bi4PM-z2.js → git-graph-xD6TLRVv.js} +1 -1
  7. package/dist/web/assets/index-CigdXBuQ.css +2 -0
  8. package/dist/web/assets/index-DBdw8tN_.js +22 -0
  9. package/dist/web/assets/keybindings-store-kHLASnRb.js +1 -0
  10. package/dist/web/assets/{markdown-renderer-ChvoCZNm.js → markdown-renderer-z99RjIxZ.js} +1 -1
  11. package/dist/web/assets/{postgres-viewer-DPsoDR4y.js → postgres-viewer-CaMySHpD.js} +1 -1
  12. package/dist/web/assets/{settings-tab-D7pNWvVE.js → settings-tab-BnDkeQWk.js} +1 -1
  13. package/dist/web/assets/{sqlite-viewer-CTPkNEEe.js → sqlite-viewer-EwHWc37J.js} +1 -1
  14. package/dist/web/assets/terminal-tab-CTN18lb6.js +36 -0
  15. package/dist/web/index.html +2 -2
  16. package/dist/web/sw.js +1 -1
  17. package/package.json +1 -1
  18. package/src/server/index.ts +3 -79
  19. package/src/server/routes/database.ts +57 -0
  20. package/src/server/routes/fs-browse.ts +67 -0
  21. package/src/services/database/sqlite-adapter.ts +1 -2
  22. package/src/services/fs-browse.service.ts +216 -0
  23. package/src/services/sqlite.service.ts +2 -1
  24. package/src/web/app.tsx +23 -19
  25. package/src/web/components/chat/message-list.tsx +8 -105
  26. package/src/web/components/chat/question-card.tsx +334 -0
  27. package/src/web/components/database/connection-form-dialog.tsx +15 -6
  28. package/src/web/components/database/connection-import-export.tsx +116 -0
  29. package/src/web/components/database/database-sidebar.tsx +12 -8
  30. package/src/web/components/database/database-viewer.tsx +6 -2
  31. package/src/web/components/database/use-connections.ts +13 -1
  32. package/src/web/components/database/use-database.ts +7 -4
  33. package/src/web/components/layout/add-project-form.tsx +23 -12
  34. package/src/web/components/layout/command-palette.tsx +1 -1
  35. package/src/web/components/projects/dir-suggest.tsx +22 -12
  36. package/src/web/components/ui/browse-button.tsx +42 -0
  37. package/src/web/components/ui/file-browser-picker.tsx +374 -0
  38. package/src/web/stores/project-store.ts +0 -14
  39. package/dist/web/assets/chat-tab-DkgRZpbj.js +0 -7
  40. package/dist/web/assets/database-viewer-BX0F2yv0.js +0 -1
  41. package/dist/web/assets/index-DSg2VjxL.css +0 -2
  42. package/dist/web/assets/index-DUb5kwfL.js +0 -21
  43. package/dist/web/assets/keybindings-store-BVTJScRw.js +0 -1
  44. package/dist/web/assets/terminal-tab-B_75oJaQ.js +0 -36
@@ -88,5 +88,17 @@ export function useConnections() {
88
88
  setCachedTables((prev) => new Map(prev).set(id, tables));
89
89
  }, []);
90
90
 
91
- return { connections, loading, cachedTables, createConnection, updateConnection, deleteConnection, testConnection, refreshTables };
91
+ const exportConnections = useCallback(async () => {
92
+ return api.get<{ version: number; exported_at: string; connections: unknown[] }>("/api/db/connections/export");
93
+ }, []);
94
+
95
+ const importConnections = useCallback(async (data: { connections: unknown[] }) => {
96
+ const result = await api.post<{ imported: number; skipped: number; errors: string[]; connections: Connection[] }>(
97
+ "/api/db/connections/import", data,
98
+ );
99
+ await fetchConnections();
100
+ return result;
101
+ }, [fetchConnections]);
102
+
103
+ return { connections, loading, cachedTables, createConnection, updateConnection, deleteConnection, testConnection, refreshTables, exportConnections, importConnections };
92
104
  }
@@ -90,19 +90,22 @@ export function useDatabase(connectionId: number) {
90
90
  try {
91
91
  const result = await api.post<DbQueryResult>(`${base}/query`, { sql: sqlText });
92
92
  setQueryResult(result);
93
- if (result.changeType === "modify") fetchTableData();
93
+ if (result.changeType === "modify") fetchTableData(selectedTable ?? undefined, selectedSchema);
94
94
  } catch (e) {
95
95
  setQueryError((e as Error).message);
96
96
  } finally {
97
97
  setQueryLoading(false);
98
98
  }
99
- }, [base, fetchTableData]);
99
+ }, [base, selectedTable, selectedSchema, fetchTableData]);
100
100
 
101
101
  const updateCell = useCallback(async (pkColumn: string, pkValue: unknown, column: string, value: unknown) => {
102
102
  if (!selectedTable) return;
103
+ const t = selectedTable;
104
+ const s = selectedSchema;
103
105
  try {
104
- await api.put(`${base}/cell`, { table: selectedTable, schema: selectedSchema, pkColumn, pkValue, column, value });
105
- fetchTableData();
106
+ await api.put(`${base}/cell`, { table: t, schema: s, pkColumn, pkValue, column, value });
107
+ // Re-fetch with explicit args to avoid stale closure
108
+ fetchTableData(t, s);
106
109
  } catch (e) {
107
110
  setError((e as Error).message);
108
111
  }
@@ -3,6 +3,7 @@ import { Loader2, FolderOpen } from "lucide-react";
3
3
  import { useProjectStore } from "@/stores/project-store";
4
4
  import { api } from "@/lib/api-client";
5
5
  import { cn } from "@/lib/utils";
6
+ import { BrowseButton } from "@/components/ui/browse-button";
6
7
 
7
8
  interface SuggestedDir {
8
9
  path: string;
@@ -83,19 +84,29 @@ export function AddProjectForm({ onSuccess, onCancel, footerClassName }: AddProj
83
84
  {/* Path input with suggestions */}
84
85
  <div ref={wrapperRef} className="relative">
85
86
  <label className="block text-xs font-medium text-foreground mb-1">Project path</label>
86
- <div className="relative flex items-center">
87
- <FolderOpen className="absolute left-2.5 size-3.5 text-text-subtle pointer-events-none" />
88
- <input
89
- type="text"
90
- value={path}
91
- onChange={(e) => { setPath(e.target.value); setError(""); }}
92
- onFocus={() => suggestions.length > 0 && setShowSuggestions(true)}
93
- placeholder="/path/to/project"
94
- className="w-full pl-8 pr-3 py-2 rounded-md border border-border bg-background text-sm focus:outline-none focus:ring-1 focus:ring-primary"
95
- autoFocus
96
- autoComplete="off"
87
+ <div className="flex gap-1.5 items-center">
88
+ <div className="relative flex items-center flex-1">
89
+ <FolderOpen className="absolute left-2.5 size-3.5 text-text-subtle pointer-events-none" />
90
+ <input
91
+ type="text"
92
+ value={path}
93
+ onChange={(e) => { setPath(e.target.value); setError(""); }}
94
+ onFocus={() => suggestions.length > 0 && setShowSuggestions(true)}
95
+ placeholder="/path/to/project"
96
+ className="w-full pl-8 pr-3 py-2 rounded-md border border-border bg-background text-sm focus:outline-none focus:ring-1 focus:ring-primary"
97
+ autoFocus
98
+ autoComplete="off"
99
+ />
100
+ {loading && <Loader2 className="absolute right-2.5 size-3.5 text-text-subtle animate-spin" />}
101
+ </div>
102
+ <BrowseButton
103
+ mode="folder"
104
+ onSelect={(selectedPath) => {
105
+ setPath(selectedPath);
106
+ if (!name) setName(selectedPath.split("/").pop() ?? "");
107
+ setError("");
108
+ }}
97
109
  />
98
- {loading && <Loader2 className="absolute right-2.5 size-3.5 text-text-subtle animate-spin" />}
99
110
  </div>
100
111
 
101
112
  {/* Suggestions dropdown */}
@@ -137,8 +137,8 @@ export function CommandPalette({ open, onClose, initialQuery = "" }: { open: boo
137
137
  };
138
138
 
139
139
  return [
140
- { id: "terminal", label: "New Terminal", icon: Terminal, action: openNewTab("terminal", "Terminal"), keywords: "bash shell console", group: "action" },
141
140
  { id: "chat", label: "New AI Chat", icon: MessageSquare, action: openNewTab("chat", "AI Chat"), keywords: "ai assistant claude", group: "action" },
141
+ { id: "terminal", label: "New Terminal", icon: Terminal, action: openNewTab("terminal", "Terminal"), keywords: "bash shell console", group: "action" },
142
142
  { id: "git-graph", label: "Git Graph", icon: GitBranch, action: openNewTab("git-graph", "Git Graph"), keywords: "branch history log", group: "action" },
143
143
  { id: "postgres", label: "PostgreSQL", icon: Database, action: openNewTab("postgres", "PostgreSQL"), keywords: "database pg sql query", group: "action" },
144
144
  { id: "git-status", label: "Git Status", icon: GitCommitHorizontal, action: () => { setSidebarActiveTab("git"); onClose(); }, keywords: "changes diff staged", group: "action" },
@@ -2,6 +2,7 @@ import { useState, useEffect, useRef, useCallback } from "react";
2
2
  import { FolderGit2, Loader2 } from "lucide-react";
3
3
  import { api } from "@/lib/api-client";
4
4
  import { Input } from "@/components/ui/input";
5
+ import { BrowseButton } from "@/components/ui/browse-button";
5
6
 
6
7
  interface DirSuggestItem {
7
8
  path: string;
@@ -105,19 +106,28 @@ export function DirSuggest({ value, onChange, onSelect, placeholder, autoFocus }
105
106
 
106
107
  return (
107
108
  <div className="relative">
108
- <div className="relative">
109
- <Input
110
- placeholder={placeholder ?? "/home/user/my-project"}
111
- value={value}
112
- onChange={(e) => onChange(e.target.value)}
113
- onKeyDown={handleKeyDown}
114
- onFocus={() => filtered.length > 0 && setShowSuggestions(true)}
115
- onBlur={() => setTimeout(() => setShowSuggestions(false), 200)}
116
- autoFocus={autoFocus}
109
+ <div className="flex gap-1.5 items-center">
110
+ <div className="relative flex-1">
111
+ <Input
112
+ placeholder={placeholder ?? "/home/user/my-project"}
113
+ value={value}
114
+ onChange={(e) => onChange(e.target.value)}
115
+ onKeyDown={handleKeyDown}
116
+ onFocus={() => filtered.length > 0 && setShowSuggestions(true)}
117
+ onBlur={() => setTimeout(() => setShowSuggestions(false), 200)}
118
+ autoFocus={autoFocus}
119
+ />
120
+ {loading && (
121
+ <Loader2 className="absolute right-2 top-1/2 -translate-y-1/2 size-4 text-text-subtle animate-spin" />
122
+ )}
123
+ </div>
124
+ <BrowseButton
125
+ mode="folder"
126
+ onSelect={(path) => {
127
+ onChange(path);
128
+ onSelect?.({ path, name: path.split("/").pop() ?? path });
129
+ }}
117
130
  />
118
- {loading && (
119
- <Loader2 className="absolute right-2 top-1/2 -translate-y-1/2 size-4 text-text-subtle animate-spin" />
120
- )}
121
131
  </div>
122
132
  {showSuggestions && filtered.length > 0 && (
123
133
  <div className="absolute z-50 left-0 right-0 top-full mt-1 max-h-48 overflow-y-auto rounded-md border border-border bg-surface shadow-lg">
@@ -0,0 +1,42 @@
1
+ import { useState } from "react";
2
+ import { FolderOpen } from "lucide-react";
3
+ import { Button } from "@/components/ui/button";
4
+ import { FileBrowserPicker, type FileBrowserPickerProps } from "./file-browser-picker";
5
+ import { cn } from "@/lib/utils";
6
+
7
+ interface BrowseButtonProps {
8
+ mode: FileBrowserPickerProps["mode"];
9
+ accept?: string[];
10
+ root?: string;
11
+ title?: string;
12
+ onSelect: (path: string) => void;
13
+ className?: string;
14
+ }
15
+
16
+ export function BrowseButton({ mode, accept, root, title, onSelect, className }: BrowseButtonProps) {
17
+ const [open, setOpen] = useState(false);
18
+
19
+ return (
20
+ <>
21
+ <Button
22
+ type="button"
23
+ variant="ghost"
24
+ size="icon"
25
+ className={cn("size-8 shrink-0", className)}
26
+ onClick={() => setOpen(true)}
27
+ title={title ?? "Browse..."}
28
+ >
29
+ <FolderOpen className="size-4" />
30
+ </Button>
31
+ <FileBrowserPicker
32
+ open={open}
33
+ mode={mode}
34
+ accept={accept}
35
+ root={root}
36
+ title={title}
37
+ onSelect={(path) => { onSelect(path); setOpen(false); }}
38
+ onCancel={() => setOpen(false)}
39
+ />
40
+ </>
41
+ );
42
+ }
@@ -0,0 +1,374 @@
1
+ import { useState, useEffect, useCallback, useRef } from "react";
2
+ import {
3
+ Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
4
+ } from "@/components/ui/dialog";
5
+ import { Button } from "@/components/ui/button";
6
+ import { ScrollArea } from "@/components/ui/scroll-area";
7
+ import { Input } from "@/components/ui/input";
8
+ import { api } from "@/lib/api-client";
9
+ import {
10
+ Folder, File, Database, Home, Monitor, FileText,
11
+ Download, ChevronRight, ArrowLeft, Search, Loader2, Clock, Eye, EyeOff,
12
+ } from "lucide-react";
13
+ import { cn } from "@/lib/utils";
14
+
15
+ // ── Types ──────────────────────────────────────────────────────────
16
+
17
+ interface BrowseEntry {
18
+ name: string;
19
+ path: string;
20
+ type: "file" | "directory";
21
+ size?: number;
22
+ modified: string;
23
+ }
24
+
25
+ interface BrowseResult {
26
+ entries: BrowseEntry[];
27
+ current: string;
28
+ parent: string | null;
29
+ breadcrumbs: { name: string; path: string }[];
30
+ }
31
+
32
+ export interface FileBrowserPickerProps {
33
+ open: boolean;
34
+ mode: "file" | "folder" | "both";
35
+ accept?: string[];
36
+ root?: string;
37
+ title?: string;
38
+ onSelect: (path: string) => void;
39
+ onCancel: () => void;
40
+ }
41
+
42
+ // ── Helpers ────────────────────────────────────────────────────────
43
+
44
+ const RECENT_KEY = "ppm-recent-paths";
45
+ const MAX_RECENT = 5;
46
+
47
+ const QUICK_ACCESS = [
48
+ { name: "Home", path: "~", icon: Home },
49
+ { name: "Desktop", path: "~/Desktop", icon: Monitor },
50
+ { name: "Documents", path: "~/Documents", icon: FileText },
51
+ { name: "Downloads", path: "~/Downloads", icon: Download },
52
+ ];
53
+
54
+ function getRecent(): string[] {
55
+ try { return JSON.parse(localStorage.getItem(RECENT_KEY) ?? "[]"); } catch { return []; }
56
+ }
57
+
58
+ function saveRecent(dirPath: string): void {
59
+ const updated = [dirPath, ...getRecent().filter((p) => p !== dirPath)].slice(0, MAX_RECENT);
60
+ localStorage.setItem(RECENT_KEY, JSON.stringify(updated));
61
+ }
62
+
63
+ function formatSize(bytes?: number): string {
64
+ if (bytes == null) return "";
65
+ if (bytes < 1024) return `${bytes} B`;
66
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
67
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
68
+ }
69
+
70
+ function formatRelativeTime(iso: string): string {
71
+ const diff = Date.now() - new Date(iso).getTime();
72
+ const mins = Math.floor(diff / 60000);
73
+ if (mins < 1) return "now";
74
+ if (mins < 60) return `${mins}m ago`;
75
+ const hrs = Math.floor(mins / 60);
76
+ if (hrs < 24) return `${hrs}h ago`;
77
+ const days = Math.floor(hrs / 24);
78
+ return `${days}d ago`;
79
+ }
80
+
81
+ function fileIcon(entry: BrowseEntry): React.ReactNode {
82
+ if (entry.type === "directory") return <Folder className="size-4 text-blue-500" />;
83
+ const ext = entry.name.split(".").pop()?.toLowerCase();
84
+ if (ext && ["db", "sqlite", "sqlite3"].includes(ext)) return <Database className="size-4 text-amber-500" />;
85
+ return <File className="size-4 text-text-subtle" />;
86
+ }
87
+
88
+ function matchesAccept(name: string, accept?: string[]): boolean {
89
+ if (!accept?.length) return true;
90
+ const ext = "." + name.split(".").pop()?.toLowerCase();
91
+ return accept.includes(ext);
92
+ }
93
+
94
+ // ── Main Component ─────────────────────────────────────────────────
95
+
96
+ export function FileBrowserPicker({
97
+ open, mode, accept, root, title, onSelect, onCancel,
98
+ }: FileBrowserPickerProps) {
99
+ const [entries, setEntries] = useState<BrowseEntry[]>([]);
100
+ const [current, setCurrent] = useState("");
101
+ const [parent, setParent] = useState<string | null>(null);
102
+ const [breadcrumbs, setBreadcrumbs] = useState<{ name: string; path: string }[]>([]);
103
+ const [selected, setSelected] = useState<string | null>(null);
104
+ const [loading, setLoading] = useState(false);
105
+ const [error, setError] = useState<string | null>(null);
106
+ const [search, setSearch] = useState("");
107
+ const [pathInput, setPathInput] = useState("");
108
+ const [showHidden, setShowHidden] = useState(false);
109
+ const [recentPaths, setRecentPaths] = useState<string[]>([]);
110
+ const listRef = useRef<HTMLDivElement>(null);
111
+ const isMobile = typeof window !== "undefined" && window.innerWidth < 768;
112
+
113
+ const defaultTitle = mode === "folder" ? "Select Folder" : mode === "file" ? "Select File" : "Select File or Folder";
114
+
115
+ const fetchDir = useCallback(async (dirPath?: string, hidden?: boolean) => {
116
+ setLoading(true);
117
+ setError(null);
118
+ setSelected(null);
119
+ setSearch("");
120
+ try {
121
+ const params = new URLSearchParams();
122
+ if (dirPath) params.set("path", dirPath);
123
+ if (hidden) params.set("showHidden", "true");
124
+ const result = await api.get<BrowseResult>(`/api/fs/browse?${params}`);
125
+ setEntries(result.entries);
126
+ setCurrent(result.current);
127
+ setParent(result.parent);
128
+ setBreadcrumbs(result.breadcrumbs);
129
+ setPathInput(result.current);
130
+ } catch (e) {
131
+ setError((e as Error).message || "Failed to browse directory");
132
+ } finally {
133
+ setLoading(false);
134
+ }
135
+ }, []);
136
+
137
+ // Fetch on open
138
+ useEffect(() => {
139
+ if (open) {
140
+ fetchDir(root ?? "~", showHidden);
141
+ setRecentPaths(getRecent());
142
+ }
143
+ }, [open, root, fetchDir, showHidden]);
144
+
145
+ const handleNavigate = (path: string) => fetchDir(path, showHidden);
146
+
147
+ const toggleHidden = () => {
148
+ const next = !showHidden;
149
+ setShowHidden(next);
150
+ fetchDir(current || (root ?? "~"), next);
151
+ };
152
+
153
+ const handlePathInputSubmit = (e: React.KeyboardEvent) => {
154
+ if (e.key === "Enter" && pathInput.trim()) {
155
+ fetchDir(pathInput.trim());
156
+ }
157
+ };
158
+
159
+ const handleEntryClick = (entry: BrowseEntry) => {
160
+ if (entry.type === "directory") {
161
+ if (mode === "file") {
162
+ // In file mode, clicking a dir navigates into it
163
+ handleNavigate(entry.path);
164
+ } else {
165
+ // In folder/both mode, clicking selects it
166
+ setSelected(entry.path);
167
+ }
168
+ } else {
169
+ // File: select if mode allows
170
+ if (mode !== "folder") {
171
+ setSelected(entry.path);
172
+ }
173
+ }
174
+ };
175
+
176
+ const handleEntryDoubleClick = (entry: BrowseEntry) => {
177
+ if (entry.type === "directory") {
178
+ handleNavigate(entry.path);
179
+ }
180
+ };
181
+
182
+ const handleConfirm = () => {
183
+ if (!selected) return;
184
+ saveRecent(current);
185
+ onSelect(selected);
186
+ };
187
+
188
+ // Filter entries by search + accept
189
+ const visible = entries.filter((e) => {
190
+ if (search && !e.name.toLowerCase().includes(search.toLowerCase())) return false;
191
+ if (e.type === "file" && accept?.length && !matchesAccept(e.name, accept)) return false;
192
+ return true;
193
+ });
194
+
195
+ const isSelectable = (entry: BrowseEntry): boolean => {
196
+ if (entry.type === "directory") return mode !== "file";
197
+ return mode !== "folder";
198
+ };
199
+
200
+ const content = (
201
+ <div className="flex flex-col flex-1 min-h-0">
202
+ {/* Path input bar */}
203
+ <div className="flex items-center gap-1.5 px-3 py-2 border-b border-border">
204
+ {parent && (
205
+ <Button variant="ghost" size="icon" className="size-7 shrink-0" onClick={() => handleNavigate(parent)}>
206
+ <ArrowLeft className="size-4" />
207
+ </Button>
208
+ )}
209
+ <Input
210
+ value={pathInput}
211
+ onChange={(e) => setPathInput(e.target.value)}
212
+ onKeyDown={handlePathInputSubmit}
213
+ placeholder="Type path and press Enter"
214
+ className="h-7 text-xs font-mono flex-1"
215
+ />
216
+ </div>
217
+
218
+ {/* Breadcrumbs */}
219
+ <div className="flex items-center gap-0.5 px-3 py-1.5 border-b border-border overflow-x-auto text-xs">
220
+ {breadcrumbs.map((crumb, i) => (
221
+ <span key={crumb.path} className="flex items-center shrink-0">
222
+ {i > 0 && <ChevronRight className="size-3 text-text-subtle mx-0.5" />}
223
+ <button
224
+ type="button"
225
+ onClick={() => handleNavigate(crumb.path)}
226
+ className="hover:text-primary hover:underline text-text-secondary"
227
+ >
228
+ {crumb.name}
229
+ </button>
230
+ </span>
231
+ ))}
232
+ </div>
233
+
234
+ {/* Main area */}
235
+ <div className="flex flex-1 min-h-0 overflow-hidden">
236
+ {/* Quick access sidebar (desktop only) */}
237
+ {!isMobile && (
238
+ <div className="w-36 border-r border-border py-2 px-1 shrink-0 overflow-y-auto">
239
+ {QUICK_ACCESS.map((qa) => (
240
+ <button
241
+ key={qa.path}
242
+ type="button"
243
+ onClick={() => handleNavigate(qa.path)}
244
+ className={cn(
245
+ "flex items-center gap-2 w-full px-2 py-1 text-xs rounded-md hover:bg-surface-hover text-left",
246
+ current.endsWith(qa.name) && "bg-primary/10 text-primary",
247
+ )}
248
+ >
249
+ <qa.icon className="size-3.5" />
250
+ {qa.name}
251
+ </button>
252
+ ))}
253
+ {recentPaths.length > 0 && (
254
+ <>
255
+ <div className="text-[10px] text-text-subtle px-2 mt-3 mb-1 font-medium">Recent</div>
256
+ {recentPaths.map((rp) => (
257
+ <button
258
+ key={rp}
259
+ type="button"
260
+ onClick={() => handleNavigate(rp)}
261
+ className="flex items-center gap-2 w-full px-2 py-1 text-xs rounded-md hover:bg-surface-hover text-left truncate"
262
+ >
263
+ <Clock className="size-3 shrink-0" />
264
+ <span className="truncate">{rp.split("/").pop()}</span>
265
+ </button>
266
+ ))}
267
+ </>
268
+ )}
269
+ </div>
270
+ )}
271
+
272
+ {/* Entry list */}
273
+ <ScrollArea className="flex-1 min-h-0">
274
+ {loading ? (
275
+ <div className="flex items-center justify-center py-12">
276
+ <Loader2 className="size-5 animate-spin text-text-subtle" />
277
+ </div>
278
+ ) : error ? (
279
+ <div className="text-center py-8 text-xs text-red-500">{error}</div>
280
+ ) : visible.length === 0 ? (
281
+ <div className="text-center py-8 text-xs text-text-subtle">
282
+ {search ? "No matching entries" : "Empty directory"}
283
+ </div>
284
+ ) : (
285
+ <div ref={listRef} className="py-1">
286
+ {visible.map((entry) => {
287
+ const selectable = isSelectable(entry);
288
+ return (
289
+ <button
290
+ key={entry.path}
291
+ type="button"
292
+ onClick={() => handleEntryClick(entry)}
293
+ onDoubleClick={() => handleEntryDoubleClick(entry)}
294
+ className={cn(
295
+ "flex items-center gap-2 w-full px-3 py-1.5 text-left text-xs transition-colors",
296
+ selected === entry.path
297
+ ? "bg-primary/10 text-primary"
298
+ : selectable
299
+ ? "hover:bg-surface-hover text-text-primary"
300
+ : "opacity-40 cursor-default",
301
+ )}
302
+ disabled={!selectable && entry.type === "file"}
303
+ >
304
+ {fileIcon(entry)}
305
+ <span className={cn("flex-1 truncate", entry.type === "directory" && "font-medium")}>
306
+ {entry.name}
307
+ </span>
308
+ <span className="text-text-subtle text-[10px] shrink-0 w-14 text-right">
309
+ {formatSize(entry.size)}
310
+ </span>
311
+ <span className="text-text-subtle text-[10px] shrink-0 w-14 text-right">
312
+ {formatRelativeTime(entry.modified)}
313
+ </span>
314
+ </button>
315
+ );
316
+ })}
317
+ </div>
318
+ )}
319
+ </ScrollArea>
320
+ </div>
321
+
322
+ {/* Footer */}
323
+ <div className="flex items-center gap-2 px-3 py-2 border-t border-border shrink-0">
324
+ <Button
325
+ variant="ghost"
326
+ size="icon"
327
+ className={cn("size-7 shrink-0", showHidden && "text-primary")}
328
+ onClick={toggleHidden}
329
+ title={showHidden ? "Hide hidden files" : "Show hidden files"}
330
+ >
331
+ {showHidden ? <Eye className="size-3.5" /> : <EyeOff className="size-3.5" />}
332
+ </Button>
333
+ {accept?.length ? (
334
+ <span className="text-[10px] text-text-subtle bg-surface-hover px-1.5 py-0.5 rounded">
335
+ {accept.join(", ")}
336
+ </span>
337
+ ) : null}
338
+ <div className="flex-1 max-w-48">
339
+ <div className="relative">
340
+ <Search className="absolute left-2 top-1/2 -translate-y-1/2 size-3 text-text-subtle" />
341
+ <Input
342
+ value={search}
343
+ onChange={(e) => setSearch(e.target.value)}
344
+ placeholder="Filter..."
345
+ className="h-6 text-[11px] pl-6"
346
+ />
347
+ </div>
348
+ </div>
349
+ <div className="flex-1" />
350
+ <Button variant="outline" size="sm" onClick={onCancel} className="h-7 text-xs">
351
+ Cancel
352
+ </Button>
353
+ <Button size="sm" onClick={handleConfirm} disabled={!selected} className="h-7 text-xs">
354
+ Select
355
+ </Button>
356
+ </div>
357
+ </div>
358
+ );
359
+
360
+ // Responsive: Dialog for desktop, simplified dialog with taller content for mobile
361
+ return (
362
+ <Dialog open={open} onOpenChange={(v) => { if (!v) onCancel(); }}>
363
+ <DialogContent className={cn(
364
+ "p-0 gap-0 overflow-hidden flex flex-col",
365
+ isMobile ? "max-w-[95vw] h-[85vh]" : "max-w-2xl h-[70vh]",
366
+ )}>
367
+ <DialogHeader className="px-3 py-2 border-b border-border">
368
+ <DialogTitle className="text-sm">{title ?? defaultTitle}</DialogTitle>
369
+ </DialogHeader>
370
+ {content}
371
+ </DialogContent>
372
+ </Dialog>
373
+ );
374
+ }
@@ -1,6 +1,5 @@
1
1
  import { create } from "zustand";
2
2
  import { api } from "@/lib/api-client";
3
- import { parseUrlState } from "@/hooks/use-url-sync";
4
3
 
5
4
  export interface Project {
6
5
  name: string;
@@ -109,19 +108,6 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({
109
108
  try {
110
109
  const projects = await api.get<ProjectInfo[]>("/api/projects");
111
110
  set({ projects, loading: false });
112
- // Auto-select: restore from URL first, then fall back to first project
113
- set((s) => {
114
- if (!s.activeProject && projects.length > 0) {
115
- const { projectName: urlProject } = parseUrlState();
116
- if (urlProject) {
117
- const match = projects.find((p) => p.name === urlProject);
118
- if (match) return { activeProject: match };
119
- }
120
- const sorted = resolveOrder(projects, s.customOrder);
121
- return { activeProject: sorted[0] };
122
- }
123
- return {};
124
- });
125
111
  } catch (err) {
126
112
  set({
127
113
  error: err instanceof Error ? err.message : "Failed to fetch projects",