@hienlh/ppm 0.6.6 → 0.7.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/CHANGELOG.md +28 -0
- package/README.md +86 -313
- package/dist/web/assets/chat-tab-CbNbBMGw.js +7 -0
- package/dist/web/assets/{code-editor-ZFl5kZ4-.js → code-editor-D6OuzcC-.js} +1 -1
- package/dist/web/assets/{database-viewer-DPpOsMqa.js → database-viewer-BxUpM_uA.js} +1 -1
- package/dist/web/assets/{diff-viewer-CX74l6lV.js → diff-viewer-DAhrHpNM.js} +1 -1
- package/dist/web/assets/{dist-Jb3Tnkpc.js → dist-CNRrBoQi.js} +14 -14
- package/dist/web/assets/git-graph-BpTt5iOd.js +1 -0
- package/dist/web/assets/index-BU_07_oW.js +29 -0
- package/dist/web/assets/index-CBQhXXeV.css +2 -0
- package/dist/web/assets/keybindings-store-C0m8_V9X.js +1 -0
- package/dist/web/assets/{markdown-renderer-Bke6DHFh.js → markdown-renderer-CvGYO9sH.js} +2 -2
- package/dist/web/assets/postgres-viewer-BL99auSm.js +1 -0
- package/dist/web/assets/{settings-tab-DD05d8rM.js → settings-tab-Bwsxb41F.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-Cx7tLyT-.js → sqlite-viewer-DfgaCbWT.js} +1 -1
- package/dist/web/assets/terminal-tab-D27e4ZTD.js +36 -0
- package/dist/web/index.html +4 -3
- package/dist/web/sw.js +1 -1
- package/package.json +1 -1
- package/src/lib/network-utils.ts +12 -0
- 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/server/routes/settings.ts +52 -0
- package/src/server/routes/tunnel.ts +1 -12
- package/src/server/ws/chat.ts +30 -3
- package/src/services/config.service.ts +1 -1
- package/src/services/fs-browse.service.ts +216 -0
- package/src/services/notification.service.ts +42 -0
- package/src/services/telegram-notification.service.ts +106 -0
- package/src/types/config.ts +6 -0
- package/src/web/app.tsx +61 -18
- 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/use-connections.ts +13 -1
- 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/layout/draggable-tab.tsx +10 -2
- package/src/web/components/layout/mobile-nav.tsx +42 -3
- package/src/web/components/layout/project-bar.tsx +16 -8
- package/src/web/components/layout/tab-bar.tsx +55 -4
- package/src/web/components/projects/dir-suggest.tsx +22 -12
- package/src/web/components/settings/settings-tab.tsx +135 -94
- package/src/web/components/settings/telegram-settings-section.tsx +113 -0
- package/src/web/components/ui/accordion.tsx +64 -0
- package/src/web/components/ui/browse-button.tsx +42 -0
- package/src/web/components/ui/file-browser-picker.tsx +374 -0
- package/src/web/hooks/use-chat.ts +29 -0
- package/src/web/hooks/use-notification-badge.ts +20 -0
- package/src/web/hooks/use-tab-overflow.ts +91 -0
- package/src/web/hooks/use-url-sync.ts +5 -2
- package/src/web/index.html +1 -0
- package/src/web/lib/favicon.ts +21 -0
- package/src/web/lib/notification-sounds.ts +61 -0
- package/src/web/stores/notification-store.ts +83 -0
- package/src/web/stores/project-store.ts +0 -14
- package/dist/web/assets/chat-tab-dwpaSkQD.js +0 -7
- package/dist/web/assets/git-graph-Dju1rygf.js +0 -1
- package/dist/web/assets/index-DSg2VjxL.css +0 -2
- package/dist/web/assets/index-DXOEmhRm.js +0 -21
- package/dist/web/assets/keybindings-store-VhiJwp77.js +0 -1
- package/dist/web/assets/postgres-viewer-DaNYnInA.js +0 -1
- package/dist/web/assets/terminal-tab-_farMLMO.js +0 -36
- /package/dist/web/assets/{tab-store-DIyJSjtr.js → tab-store-Bm1Hw8OR.js} +0 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { useState, useEffect } from "react";
|
|
2
|
+
import { Send } from "lucide-react";
|
|
3
|
+
import { Button } from "@/components/ui/button";
|
|
4
|
+
import { Input } from "@/components/ui/input";
|
|
5
|
+
import { api } from "@/lib/api-client";
|
|
6
|
+
|
|
7
|
+
interface TelegramConfig {
|
|
8
|
+
bot_token: string;
|
|
9
|
+
chat_id: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function TelegramSettingsSection() {
|
|
13
|
+
const [config, setConfig] = useState<TelegramConfig>({ bot_token: "", chat_id: "" });
|
|
14
|
+
const [tokenInput, setTokenInput] = useState("");
|
|
15
|
+
const [chatIdInput, setChatIdInput] = useState("");
|
|
16
|
+
const [saving, setSaving] = useState(false);
|
|
17
|
+
const [testing, setTesting] = useState(false);
|
|
18
|
+
const [status, setStatus] = useState<{ type: "ok" | "err"; msg: string } | null>(null);
|
|
19
|
+
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
api.get<TelegramConfig>("/api/settings/telegram").then((data) => {
|
|
22
|
+
setConfig(data);
|
|
23
|
+
setChatIdInput(data.chat_id);
|
|
24
|
+
}).catch(() => {});
|
|
25
|
+
}, []);
|
|
26
|
+
|
|
27
|
+
const save = async () => {
|
|
28
|
+
setSaving(true);
|
|
29
|
+
setStatus(null);
|
|
30
|
+
try {
|
|
31
|
+
const body: Record<string, string> = { chat_id: chatIdInput };
|
|
32
|
+
if (tokenInput) body.bot_token = tokenInput;
|
|
33
|
+
const data = await api.put<TelegramConfig>("/api/settings/telegram", body);
|
|
34
|
+
setConfig(data);
|
|
35
|
+
setTokenInput("");
|
|
36
|
+
setStatus({ type: "ok", msg: "Saved" });
|
|
37
|
+
} catch (e) {
|
|
38
|
+
setStatus({ type: "err", msg: (e as Error).message });
|
|
39
|
+
} finally {
|
|
40
|
+
setSaving(false);
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const test = async () => {
|
|
45
|
+
setTesting(true);
|
|
46
|
+
setStatus(null);
|
|
47
|
+
try {
|
|
48
|
+
// Send current input values; backend falls back to saved config for empty fields
|
|
49
|
+
await api.post("/api/settings/telegram/test", {
|
|
50
|
+
...(tokenInput ? { bot_token: tokenInput } : {}),
|
|
51
|
+
...(chatIdInput ? { chat_id: chatIdInput } : {}),
|
|
52
|
+
});
|
|
53
|
+
setStatus({ type: "ok", msg: "Test message sent!" });
|
|
54
|
+
} catch (e) {
|
|
55
|
+
setStatus({ type: "err", msg: (e as Error).message });
|
|
56
|
+
} finally {
|
|
57
|
+
setTesting(false);
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const isConfigured = !!config.bot_token;
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<div className="space-y-2">
|
|
65
|
+
<div className="space-y-1.5">
|
|
66
|
+
<label className="text-[11px] text-text-subtle">Bot Token</label>
|
|
67
|
+
<Input
|
|
68
|
+
type="password"
|
|
69
|
+
placeholder={isConfigured ? "•••••• (saved)" : "123456:ABC-DEF..."}
|
|
70
|
+
value={tokenInput}
|
|
71
|
+
onChange={(e) => setTokenInput(e.target.value)}
|
|
72
|
+
className="h-7 text-xs"
|
|
73
|
+
/>
|
|
74
|
+
</div>
|
|
75
|
+
<div className="space-y-1.5">
|
|
76
|
+
<label className="text-[11px] text-text-subtle">Chat ID</label>
|
|
77
|
+
<Input
|
|
78
|
+
placeholder="-1001234567890"
|
|
79
|
+
value={chatIdInput}
|
|
80
|
+
onChange={(e) => setChatIdInput(e.target.value)}
|
|
81
|
+
className="h-7 text-xs"
|
|
82
|
+
/>
|
|
83
|
+
<p className="text-[10px] text-text-subtle">Personal or group chat ID</p>
|
|
84
|
+
</div>
|
|
85
|
+
<div className="flex gap-1.5">
|
|
86
|
+
<Button
|
|
87
|
+
variant="default"
|
|
88
|
+
size="sm"
|
|
89
|
+
className="h-7 text-xs flex-1"
|
|
90
|
+
disabled={saving || (!tokenInput && !chatIdInput)}
|
|
91
|
+
onClick={save}
|
|
92
|
+
>
|
|
93
|
+
{saving ? "..." : "Save"}
|
|
94
|
+
</Button>
|
|
95
|
+
<Button
|
|
96
|
+
variant="outline"
|
|
97
|
+
size="sm"
|
|
98
|
+
className="h-7 text-xs gap-1"
|
|
99
|
+
disabled={testing || !isConfigured}
|
|
100
|
+
onClick={test}
|
|
101
|
+
>
|
|
102
|
+
<Send className="size-3" />
|
|
103
|
+
{testing ? "..." : "Test"}
|
|
104
|
+
</Button>
|
|
105
|
+
</div>
|
|
106
|
+
{status && (
|
|
107
|
+
<p className={`text-[11px] ${status.type === "ok" ? "text-green-600 dark:text-green-400" : "text-destructive"}`}>
|
|
108
|
+
{status.msg}
|
|
109
|
+
</p>
|
|
110
|
+
)}
|
|
111
|
+
</div>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import * as React from "react"
|
|
2
|
+
import { ChevronDownIcon } from "lucide-react"
|
|
3
|
+
import { Accordion as AccordionPrimitive } from "radix-ui"
|
|
4
|
+
|
|
5
|
+
import { cn } from "@/lib/utils"
|
|
6
|
+
|
|
7
|
+
function Accordion({
|
|
8
|
+
...props
|
|
9
|
+
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
|
|
10
|
+
return <AccordionPrimitive.Root data-slot="accordion" {...props} />
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function AccordionItem({
|
|
14
|
+
className,
|
|
15
|
+
...props
|
|
16
|
+
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
|
|
17
|
+
return (
|
|
18
|
+
<AccordionPrimitive.Item
|
|
19
|
+
data-slot="accordion-item"
|
|
20
|
+
className={cn("border-b last:border-b-0", className)}
|
|
21
|
+
{...props}
|
|
22
|
+
/>
|
|
23
|
+
)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function AccordionTrigger({
|
|
27
|
+
className,
|
|
28
|
+
children,
|
|
29
|
+
...props
|
|
30
|
+
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
|
|
31
|
+
return (
|
|
32
|
+
<AccordionPrimitive.Header className="flex">
|
|
33
|
+
<AccordionPrimitive.Trigger
|
|
34
|
+
data-slot="accordion-trigger"
|
|
35
|
+
className={cn(
|
|
36
|
+
"flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
|
|
37
|
+
className
|
|
38
|
+
)}
|
|
39
|
+
{...props}
|
|
40
|
+
>
|
|
41
|
+
{children}
|
|
42
|
+
<ChevronDownIcon className="pointer-events-none size-4 shrink-0 translate-y-0.5 text-muted-foreground transition-transform duration-200" />
|
|
43
|
+
</AccordionPrimitive.Trigger>
|
|
44
|
+
</AccordionPrimitive.Header>
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function AccordionContent({
|
|
49
|
+
className,
|
|
50
|
+
children,
|
|
51
|
+
...props
|
|
52
|
+
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
|
|
53
|
+
return (
|
|
54
|
+
<AccordionPrimitive.Content
|
|
55
|
+
data-slot="accordion-content"
|
|
56
|
+
className="overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
|
|
57
|
+
{...props}
|
|
58
|
+
>
|
|
59
|
+
<div className={cn("pt-0 pb-4", className)}>{children}</div>
|
|
60
|
+
</AccordionPrimitive.Content>
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
|
|
@@ -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,9 @@
|
|
|
1
1
|
import { useState, useCallback, useRef, useEffect } from "react";
|
|
2
2
|
import { useWebSocket } from "./use-websocket";
|
|
3
3
|
import { getAuthToken, projectUrl } from "@/lib/api-client";
|
|
4
|
+
import { useNotificationStore } from "@/stores/notification-store";
|
|
5
|
+
import { usePanelStore } from "@/stores/panel-store";
|
|
6
|
+
import { playNotificationSound } from "@/lib/notification-sounds";
|
|
4
7
|
import type { ChatMessage, ChatEvent } from "../../types/chat";
|
|
5
8
|
import type { ChatWsServerMessage } from "../../types/api";
|
|
6
9
|
|
|
@@ -33,6 +36,16 @@ interface UseChatReturn {
|
|
|
33
36
|
isConnected: boolean;
|
|
34
37
|
}
|
|
35
38
|
|
|
39
|
+
/** Check if the chat tab for this session is the active foreground tab */
|
|
40
|
+
function isSessionTabActive(sid: string): boolean {
|
|
41
|
+
if (document.hidden) return false;
|
|
42
|
+
const { panels, focusedPanelId } = usePanelStore.getState();
|
|
43
|
+
const panel = panels[focusedPanelId];
|
|
44
|
+
if (!panel) return false;
|
|
45
|
+
const activeTab = panel.tabs.find((t) => t.id === panel.activeTabId);
|
|
46
|
+
return activeTab?.type === "chat" && activeTab.metadata?.sessionId === sid;
|
|
47
|
+
}
|
|
48
|
+
|
|
36
49
|
export function useChat(sessionId: string | null, providerId = "claude", projectName = ""): UseChatReturn {
|
|
37
50
|
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
|
38
51
|
const [messagesLoading, setMessagesLoading] = useState(false);
|
|
@@ -52,6 +65,11 @@ export function useChat(sessionId: string | null, providerId = "claude", project
|
|
|
52
65
|
const pendingMessageRef = useRef<string | null>(null);
|
|
53
66
|
const sendRef = useRef<(data: string) => void>(() => {});
|
|
54
67
|
const refetchRef = useRef<(() => void) | null>(null);
|
|
68
|
+
// Refs for notification dispatch inside handleMessage (which has [] deps)
|
|
69
|
+
const sessionIdRef = useRef(sessionId);
|
|
70
|
+
sessionIdRef.current = sessionId;
|
|
71
|
+
const projectNameRef = useRef(projectName);
|
|
72
|
+
projectNameRef.current = projectName;
|
|
55
73
|
|
|
56
74
|
const handleMessage = useCallback((event: MessageEvent) => {
|
|
57
75
|
let data: ChatWsServerMessage;
|
|
@@ -215,6 +233,12 @@ export function useChat(sessionId: string | null, providerId = "claude", project
|
|
|
215
233
|
tool: data.tool,
|
|
216
234
|
input: data.input,
|
|
217
235
|
});
|
|
236
|
+
// Local notification badge — only if this tab is NOT active
|
|
237
|
+
if (sessionIdRef.current && !isSessionTabActive(sessionIdRef.current)) {
|
|
238
|
+
const nType = data.tool === "AskUserQuestion" ? "question" : "approval_request";
|
|
239
|
+
useNotificationStore.getState().addNotification(sessionIdRef.current, nType, projectNameRef.current);
|
|
240
|
+
playNotificationSound(nType);
|
|
241
|
+
}
|
|
218
242
|
break;
|
|
219
243
|
}
|
|
220
244
|
|
|
@@ -253,6 +277,11 @@ export function useChat(sessionId: string | null, providerId = "claude", project
|
|
|
253
277
|
if (data.contextWindowPct != null) {
|
|
254
278
|
setContextWindowPct(data.contextWindowPct);
|
|
255
279
|
}
|
|
280
|
+
// Local notification badge — only if this tab is NOT active
|
|
281
|
+
if (sessionIdRef.current && !isSessionTabActive(sessionIdRef.current)) {
|
|
282
|
+
useNotificationStore.getState().addNotification(sessionIdRef.current, "done", projectNameRef.current);
|
|
283
|
+
playNotificationSound("done");
|
|
284
|
+
}
|
|
256
285
|
// Finalize the streaming message — capture refs before clearing
|
|
257
286
|
const finalContent = streamingContentRef.current;
|
|
258
287
|
const finalEvents = [...streamingEventsRef.current];
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { useEffect } from "react";
|
|
2
|
+
import { useNotificationStore, selectTotalUnread } from "@/stores/notification-store";
|
|
3
|
+
import { setFavicon } from "@/lib/favicon";
|
|
4
|
+
|
|
5
|
+
const DEFAULT_TITLE = "PPM — Personal Project Manager";
|
|
6
|
+
|
|
7
|
+
/** Syncs document.title and favicon with unread notification count */
|
|
8
|
+
export function useNotificationBadge(): void {
|
|
9
|
+
const totalUnread = useNotificationStore(selectTotalUnread);
|
|
10
|
+
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
if (totalUnread > 0) {
|
|
13
|
+
document.title = `(${totalUnread}) PPM`;
|
|
14
|
+
setFavicon(true);
|
|
15
|
+
} else {
|
|
16
|
+
document.title = DEFAULT_TITLE;
|
|
17
|
+
setFavicon(false);
|
|
18
|
+
}
|
|
19
|
+
}, [totalUnread]);
|
|
20
|
+
}
|