@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.
- package/CHANGELOG.md +22 -5
- package/dist/web/assets/chat-tab-CWhxhPKH.js +7 -0
- package/dist/web/assets/{code-editor-CVMeIylx.js → code-editor-BUg7alP6.js} +1 -1
- package/dist/web/assets/database-viewer-CAgZOkZc.js +1 -0
- package/dist/web/assets/{diff-viewer-B1vnegRS.js → diff-viewer-DVvY1aFb.js} +1 -1
- package/dist/web/assets/{git-graph-Bi4PM-z2.js → git-graph-xD6TLRVv.js} +1 -1
- package/dist/web/assets/index-CigdXBuQ.css +2 -0
- package/dist/web/assets/index-DBdw8tN_.js +22 -0
- package/dist/web/assets/keybindings-store-kHLASnRb.js +1 -0
- package/dist/web/assets/{markdown-renderer-ChvoCZNm.js → markdown-renderer-z99RjIxZ.js} +1 -1
- package/dist/web/assets/{postgres-viewer-DPsoDR4y.js → postgres-viewer-CaMySHpD.js} +1 -1
- package/dist/web/assets/{settings-tab-D7pNWvVE.js → settings-tab-BnDkeQWk.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-CTPkNEEe.js → sqlite-viewer-EwHWc37J.js} +1 -1
- package/dist/web/assets/terminal-tab-CTN18lb6.js +36 -0
- package/dist/web/index.html +2 -2
- package/dist/web/sw.js +1 -1
- package/package.json +1 -1
- package/src/server/index.ts +3 -79
- package/src/server/routes/database.ts +57 -0
- package/src/server/routes/fs-browse.ts +67 -0
- package/src/services/database/sqlite-adapter.ts +1 -2
- package/src/services/fs-browse.service.ts +216 -0
- package/src/services/sqlite.service.ts +2 -1
- package/src/web/app.tsx +23 -19
- package/src/web/components/chat/message-list.tsx +8 -105
- package/src/web/components/chat/question-card.tsx +334 -0
- package/src/web/components/database/connection-form-dialog.tsx +15 -6
- package/src/web/components/database/connection-import-export.tsx +116 -0
- package/src/web/components/database/database-sidebar.tsx +12 -8
- package/src/web/components/database/database-viewer.tsx +6 -2
- package/src/web/components/database/use-connections.ts +13 -1
- package/src/web/components/database/use-database.ts +7 -4
- package/src/web/components/layout/add-project-form.tsx +23 -12
- package/src/web/components/layout/command-palette.tsx +1 -1
- package/src/web/components/projects/dir-suggest.tsx +22 -12
- package/src/web/components/ui/browse-button.tsx +42 -0
- package/src/web/components/ui/file-browser-picker.tsx +374 -0
- package/src/web/stores/project-store.ts +0 -14
- package/dist/web/assets/chat-tab-DkgRZpbj.js +0 -7
- package/dist/web/assets/database-viewer-BX0F2yv0.js +0 -1
- package/dist/web/assets/index-DSg2VjxL.css +0 -2
- package/dist/web/assets/index-DUb5kwfL.js +0 -21
- package/dist/web/assets/keybindings-store-BVTJScRw.js +0 -1
- 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
|
-
|
|
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:
|
|
105
|
-
|
|
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="
|
|
87
|
-
<
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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="
|
|
109
|
-
<
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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",
|