@hienlh/ppm 0.1.4 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +45 -0
- package/bun.lock +55 -0
- package/dist/ppm +0 -0
- package/dist/web/assets/api-client-BgVufYKf.js +1 -0
- package/dist/web/assets/arrow-up-from-line-DjfWTP75.js +1 -0
- package/dist/web/assets/button-KIZetva8.js +41 -0
- package/dist/web/assets/chat-tab-D7dR7kbZ.js +6 -0
- package/dist/web/assets/code-editor-r8P6Gk4M.js +2 -0
- package/dist/web/assets/copy-B-kLwqzg.js +1 -0
- package/dist/web/assets/dialog-D8ulRTfX.js +5 -0
- package/dist/web/assets/diff-viewer-vSvrem_i.js +4 -0
- package/dist/web/assets/dist-C4W3AGh3.js +1 -0
- package/dist/web/assets/dist-PA84y4Ga.js +1 -0
- package/dist/web/assets/external-link-Dim3NH6h.js +1 -0
- package/dist/web/assets/git-graph-Cn-s1k0-.js +1 -0
- package/dist/web/assets/git-status-panel-QjAQzNAi.js +1 -0
- package/dist/web/assets/index-DUBI96T5.css +2 -0
- package/dist/web/assets/index-nk1dAWff.js +10 -0
- package/dist/web/assets/{jsx-runtime-BnxRlLMJ.js → jsx-runtime-BFALxl05.js} +1 -1
- package/dist/web/assets/marked.esm-Cv8mjgnt.js +59 -0
- package/dist/web/assets/project-list-DqiatpaH.js +1 -0
- package/dist/web/assets/{react-Uzd0zARU.js → react-BSLFEYu8.js} +1 -1
- package/dist/web/assets/refresh-cw-DJSjl6Ev.js +1 -0
- package/dist/web/assets/settings-tab-iCGeFFdt.js +1 -0
- package/dist/web/assets/terminal-tab-DDf6S-Tu.js +36 -0
- package/dist/web/assets/trash-2-CjahwKg8.js +1 -0
- package/dist/web/assets/x-BxhOxZ5p.js +1 -0
- package/dist/web/index.html +11 -10
- package/dist/web/sw.js +1 -1
- package/docs/claude-agent-sdk-reference.md +780 -0
- package/docs/code-standards.md +74 -0
- package/docs/codebase-summary.md +22 -20
- package/docs/deployment-guide.md +81 -11
- package/docs/lessons-learned.md +58 -0
- package/docs/project-overview-pdr.md +62 -2
- package/docs/system-architecture.md +102 -10
- package/package.json +4 -1
- package/schemas/ppm-config.schema.json +87 -0
- package/src/cli/commands/init.ts +186 -43
- package/src/cli/commands/status.ts +73 -0
- package/src/cli/commands/stop.ts +24 -10
- package/src/index.ts +28 -5
- package/src/providers/claude-agent-sdk.ts +84 -3
- package/src/providers/registry.ts +0 -2
- package/src/server/index.ts +106 -15
- package/src/server/routes/settings.ts +70 -0
- package/src/server/ws/chat.ts +8 -6
- package/src/services/cloudflared.service.ts +99 -0
- package/src/services/git.service.ts +23 -1
- package/src/services/tunnel.service.ts +100 -0
- package/src/types/chat.ts +8 -1
- package/src/types/config.ts +50 -3
- package/src/web/app.tsx +10 -2
- package/src/web/components/auth/login-screen.tsx +1 -1
- package/src/web/components/chat/message-input.tsx +1 -1
- package/src/web/components/chat/message-list.tsx +112 -251
- package/src/web/components/chat/tool-cards.tsx +411 -0
- package/src/web/components/editor/code-editor.tsx +80 -20
- package/src/web/components/editor/diff-viewer.tsx +72 -7
- package/src/web/components/git/git-graph.tsx +3 -0
- package/src/web/components/git/git-status-panel.tsx +50 -1
- package/src/web/components/layout/command-palette.tsx +215 -0
- package/src/web/components/layout/mobile-drawer.tsx +143 -42
- package/src/web/components/layout/sidebar.tsx +103 -67
- package/src/web/components/layout/tab-bar.tsx +1 -2
- package/src/web/components/settings/ai-settings-section.tsx +166 -0
- package/src/web/components/settings/settings-tab.tsx +5 -0
- package/src/web/components/terminal/terminal-tab.tsx +45 -22
- package/src/web/components/ui/input.tsx +4 -3
- package/src/web/components/ui/label.tsx +24 -0
- package/src/web/components/ui/select.tsx +188 -0
- package/src/web/hooks/use-global-keybindings.ts +56 -0
- package/src/web/hooks/use-terminal.ts +14 -1
- package/src/web/lib/api-settings.ts +24 -0
- package/src/web/stores/project-store.ts +47 -2
- package/src/web/stores/tab-store.ts +1 -1
- package/src/web/styles/globals.css +20 -6
- package/test-tool.mjs +41 -0
- package/dist/web/assets/api-client-Bnf9LAt4.js +0 -1
- package/dist/web/assets/arrow-up-from-line-BXL5dtbG.js +0 -1
- package/dist/web/assets/button-DxRZgE8F.js +0 -1
- package/dist/web/assets/chat-tab-p2mwkdec.js +0 -61
- package/dist/web/assets/code-editor-vMRyRKV3.js +0 -2
- package/dist/web/assets/createLucideIcon-Dy1wlrF7.js +0 -1
- package/dist/web/assets/dialog-Db6prp1p.js +0 -45
- package/dist/web/assets/diff-viewer-BdDje3Wr.js +0 -4
- package/dist/web/assets/external-link-WSiY-639.js +0 -1
- package/dist/web/assets/git-graph-B-qwuFoO.js +0 -1
- package/dist/web/assets/git-status-panel-NkZFb5v1.js +0 -1
- package/dist/web/assets/index-BHEFCU01.js +0 -10
- package/dist/web/assets/index-DYd_2slk.css +0 -2
- package/dist/web/assets/project-list-NkR7IHT5.js +0 -1
- package/dist/web/assets/refresh-cw-DtopuYJf.js +0 -1
- package/dist/web/assets/settings-tab-DKx0s3Q1.js +0 -1
- package/dist/web/assets/terminal-tab-DHwn2LMT.js +0 -36
- package/dist/web/assets/trash-2-CHLebaNh.js +0 -1
- package/dist/web/assets/x-BISR7bpK.js +0 -1
- package/src/providers/claude-binary-finder.ts +0 -256
- package/src/providers/claude-code-cli.ts +0 -413
- package/src/providers/claude-process-registry.ts +0 -106
- /package/dist/web/assets/{dist-CSp7ir0r.js → dist-CBiGQxfr.js} +0 -0
- /package/dist/web/assets/{utils-CiBGfeHD.js → utils-DpJF9mAi.js} +0 -0
|
@@ -82,6 +82,9 @@ export function GitGraph({ metadata }: GitGraphProps) {
|
|
|
82
82
|
|
|
83
83
|
useEffect(() => {
|
|
84
84
|
fetchGraph();
|
|
85
|
+
// Auto-reload every 10 seconds
|
|
86
|
+
const interval = setInterval(fetchGraph, 10000);
|
|
87
|
+
return () => clearInterval(interval);
|
|
85
88
|
}, [fetchGraph]);
|
|
86
89
|
|
|
87
90
|
const gitAction = async (
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
FolderTree,
|
|
12
12
|
ChevronRight,
|
|
13
13
|
ChevronDown,
|
|
14
|
+
FileText,
|
|
14
15
|
} from "lucide-react";
|
|
15
16
|
import { api, projectUrl } from "@/lib/api-client";
|
|
16
17
|
import { useTabStore } from "@/stores/tab-store";
|
|
@@ -133,6 +134,9 @@ export function GitStatusPanel({ metadata, tabId }: GitStatusPanelProps) {
|
|
|
133
134
|
|
|
134
135
|
useEffect(() => {
|
|
135
136
|
fetchStatus();
|
|
137
|
+
// Auto-reload every 5 seconds
|
|
138
|
+
const interval = setInterval(fetchStatus, 5000);
|
|
139
|
+
return () => clearInterval(interval);
|
|
136
140
|
}, [fetchStatus]);
|
|
137
141
|
|
|
138
142
|
const stageFiles = async (files: string[]) => {
|
|
@@ -235,6 +239,19 @@ export function GitStatusPanel({ metadata, tabId }: GitStatusPanelProps) {
|
|
|
235
239
|
});
|
|
236
240
|
};
|
|
237
241
|
|
|
242
|
+
const openFile = (file: GitFileChange) => {
|
|
243
|
+
openTab({
|
|
244
|
+
type: "editor",
|
|
245
|
+
title: file.path.split("/").pop() ?? file.path,
|
|
246
|
+
closable: true,
|
|
247
|
+
metadata: {
|
|
248
|
+
projectName,
|
|
249
|
+
filePath: file.path,
|
|
250
|
+
},
|
|
251
|
+
projectId: projectName ?? null,
|
|
252
|
+
});
|
|
253
|
+
};
|
|
254
|
+
|
|
238
255
|
const allUnstaged = useMemo(
|
|
239
256
|
() => [
|
|
240
257
|
...(status?.unstaged ?? []),
|
|
@@ -333,6 +350,7 @@ export function GitStatusPanel({ metadata, tabId }: GitStatusPanelProps) {
|
|
|
333
350
|
actionAllLabel="Unstage All"
|
|
334
351
|
onFolderAction={(files) => unstageFiles(files.map((f) => f.path))}
|
|
335
352
|
onClickFile={openDiff}
|
|
353
|
+
onOpenFile={openFile}
|
|
336
354
|
disabled={acting}
|
|
337
355
|
/>
|
|
338
356
|
|
|
@@ -353,6 +371,7 @@ export function GitStatusPanel({ metadata, tabId }: GitStatusPanelProps) {
|
|
|
353
371
|
actionAllLabel="Stage All"
|
|
354
372
|
onFolderAction={(files) => stageFiles(files.map((f) => f.path))}
|
|
355
373
|
onClickFile={openDiff}
|
|
374
|
+
onOpenFile={openFile}
|
|
356
375
|
disabled={acting}
|
|
357
376
|
showRevert
|
|
358
377
|
onRevert={(f) =>
|
|
@@ -371,7 +390,7 @@ export function GitStatusPanel({ metadata, tabId }: GitStatusPanelProps) {
|
|
|
371
390
|
{/* Commit section */}
|
|
372
391
|
<div className="border-t p-2 space-y-2 shrink-0">
|
|
373
392
|
<textarea
|
|
374
|
-
className="w-full h-16 px-
|
|
393
|
+
className="w-full h-16 px-3 py-2 text-base md:text-sm text-foreground bg-surface border border-border rounded-lg resize-none focus:outline-none focus:border-ring placeholder:text-muted-foreground"
|
|
375
394
|
placeholder="Commit message..."
|
|
376
395
|
value={commitMsg}
|
|
377
396
|
onChange={(e) => setCommitMsg(e.target.value)}
|
|
@@ -470,6 +489,7 @@ function ActionButtons({
|
|
|
470
489
|
showRevert,
|
|
471
490
|
onRevert,
|
|
472
491
|
onAction,
|
|
492
|
+
onOpenFile,
|
|
473
493
|
actionIcon,
|
|
474
494
|
actionTitle,
|
|
475
495
|
disabled,
|
|
@@ -477,12 +497,27 @@ function ActionButtons({
|
|
|
477
497
|
showRevert?: boolean;
|
|
478
498
|
onRevert?: () => void;
|
|
479
499
|
onAction: () => void;
|
|
500
|
+
onOpenFile?: () => void;
|
|
480
501
|
actionIcon: React.ReactNode;
|
|
481
502
|
actionTitle: string;
|
|
482
503
|
disabled: boolean;
|
|
483
504
|
}) {
|
|
484
505
|
return (
|
|
485
506
|
<div className="flex items-center gap-0.5 shrink-0 ml-1">
|
|
507
|
+
{onOpenFile && (
|
|
508
|
+
<button
|
|
509
|
+
type="button"
|
|
510
|
+
className="flex items-center justify-center size-7 rounded border border-border/60 bg-muted/60 text-muted-foreground hover:bg-primary/15 hover:text-primary hover:border-primary/40 active:scale-95 transition-colors"
|
|
511
|
+
onClick={(e) => {
|
|
512
|
+
e.stopPropagation();
|
|
513
|
+
onOpenFile();
|
|
514
|
+
}}
|
|
515
|
+
disabled={disabled}
|
|
516
|
+
title="Open file"
|
|
517
|
+
>
|
|
518
|
+
<FileText className="size-3.5" />
|
|
519
|
+
</button>
|
|
520
|
+
)}
|
|
486
521
|
{showRevert && onRevert && (
|
|
487
522
|
<button
|
|
488
523
|
type="button"
|
|
@@ -529,6 +564,7 @@ function FileSection({
|
|
|
529
564
|
actionAllLabel,
|
|
530
565
|
onFolderAction,
|
|
531
566
|
onClickFile,
|
|
567
|
+
onOpenFile,
|
|
532
568
|
disabled,
|
|
533
569
|
showRevert,
|
|
534
570
|
onRevert,
|
|
@@ -545,6 +581,7 @@ function FileSection({
|
|
|
545
581
|
actionAllLabel: string;
|
|
546
582
|
onFolderAction?: (files: GitFileChange[]) => void;
|
|
547
583
|
onClickFile: (f: GitFileChange) => void;
|
|
584
|
+
onOpenFile?: (f: GitFileChange) => void;
|
|
548
585
|
disabled: boolean;
|
|
549
586
|
showRevert?: boolean;
|
|
550
587
|
onRevert?: (f: GitFileChange) => void;
|
|
@@ -580,6 +617,7 @@ function FileSection({
|
|
|
580
617
|
actionTitle={actionTitle}
|
|
581
618
|
onAction={onAction}
|
|
582
619
|
onClickFile={onClickFile}
|
|
620
|
+
onOpenFile={onOpenFile}
|
|
583
621
|
disabled={disabled}
|
|
584
622
|
showRevert={showRevert}
|
|
585
623
|
onRevert={onRevert}
|
|
@@ -594,6 +632,7 @@ function FileSection({
|
|
|
594
632
|
onAction={onAction}
|
|
595
633
|
onFolderAction={onFolderAction}
|
|
596
634
|
onClickFile={onClickFile}
|
|
635
|
+
onOpenFile={onOpenFile}
|
|
597
636
|
disabled={disabled}
|
|
598
637
|
showRevert={showRevert}
|
|
599
638
|
onRevert={onRevert}
|
|
@@ -614,6 +653,7 @@ function FileRow({
|
|
|
614
653
|
actionTitle,
|
|
615
654
|
onAction,
|
|
616
655
|
onClickFile,
|
|
656
|
+
onOpenFile,
|
|
617
657
|
disabled,
|
|
618
658
|
showRevert,
|
|
619
659
|
onRevert,
|
|
@@ -624,6 +664,7 @@ function FileRow({
|
|
|
624
664
|
actionTitle: string;
|
|
625
665
|
onAction: (f: GitFileChange) => void;
|
|
626
666
|
onClickFile: (f: GitFileChange) => void;
|
|
667
|
+
onOpenFile?: (f: GitFileChange) => void;
|
|
627
668
|
disabled: boolean;
|
|
628
669
|
showRevert?: boolean;
|
|
629
670
|
onRevert?: (f: GitFileChange) => void;
|
|
@@ -647,6 +688,7 @@ function FileRow({
|
|
|
647
688
|
<ActionButtons
|
|
648
689
|
showRevert={showRevert}
|
|
649
690
|
onRevert={onRevert ? () => onRevert(file) : undefined}
|
|
691
|
+
onOpenFile={onOpenFile ? () => onOpenFile(file) : undefined}
|
|
650
692
|
onAction={() => onAction(file)}
|
|
651
693
|
actionIcon={actionIcon}
|
|
652
694
|
actionTitle={actionTitle}
|
|
@@ -667,6 +709,7 @@ function TreeView({
|
|
|
667
709
|
onAction,
|
|
668
710
|
onFolderAction,
|
|
669
711
|
onClickFile,
|
|
712
|
+
onOpenFile,
|
|
670
713
|
disabled,
|
|
671
714
|
showRevert,
|
|
672
715
|
onRevert,
|
|
@@ -678,6 +721,7 @@ function TreeView({
|
|
|
678
721
|
onAction: (f: GitFileChange) => void;
|
|
679
722
|
onFolderAction?: (files: GitFileChange[]) => void;
|
|
680
723
|
onClickFile: (f: GitFileChange) => void;
|
|
724
|
+
onOpenFile?: (f: GitFileChange) => void;
|
|
681
725
|
disabled: boolean;
|
|
682
726
|
showRevert?: boolean;
|
|
683
727
|
onRevert?: (f: GitFileChange) => void;
|
|
@@ -698,6 +742,7 @@ function TreeView({
|
|
|
698
742
|
onAction={onAction}
|
|
699
743
|
onFolderAction={onFolderAction}
|
|
700
744
|
onClickFile={onClickFile}
|
|
745
|
+
onOpenFile={onOpenFile}
|
|
701
746
|
disabled={disabled}
|
|
702
747
|
showRevert={showRevert}
|
|
703
748
|
onRevert={onRevert}
|
|
@@ -721,6 +766,7 @@ function TreeNodeView({
|
|
|
721
766
|
onAction,
|
|
722
767
|
onFolderAction,
|
|
723
768
|
onClickFile,
|
|
769
|
+
onOpenFile,
|
|
724
770
|
disabled,
|
|
725
771
|
showRevert,
|
|
726
772
|
onRevert,
|
|
@@ -734,6 +780,7 @@ function TreeNodeView({
|
|
|
734
780
|
onAction: (f: GitFileChange) => void;
|
|
735
781
|
onFolderAction?: (files: GitFileChange[]) => void;
|
|
736
782
|
onClickFile: (f: GitFileChange) => void;
|
|
783
|
+
onOpenFile?: (f: GitFileChange) => void;
|
|
737
784
|
disabled: boolean;
|
|
738
785
|
showRevert?: boolean;
|
|
739
786
|
onRevert?: (f: GitFileChange) => void;
|
|
@@ -762,6 +809,7 @@ function TreeNodeView({
|
|
|
762
809
|
actionTitle={actionTitle}
|
|
763
810
|
onAction={onAction}
|
|
764
811
|
onClickFile={onClickFile}
|
|
812
|
+
onOpenFile={onOpenFile}
|
|
765
813
|
disabled={disabled}
|
|
766
814
|
showRevert={showRevert}
|
|
767
815
|
onRevert={onRevert}
|
|
@@ -834,6 +882,7 @@ function TreeNodeView({
|
|
|
834
882
|
onAction={onAction}
|
|
835
883
|
onFolderAction={onFolderAction}
|
|
836
884
|
onClickFile={onClickFile}
|
|
885
|
+
onOpenFile={onOpenFile}
|
|
837
886
|
disabled={disabled}
|
|
838
887
|
showRevert={showRevert}
|
|
839
888
|
onRevert={onRevert}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { useState, useEffect, useRef, useMemo } from "react";
|
|
2
|
+
import {
|
|
3
|
+
Terminal,
|
|
4
|
+
MessageSquare,
|
|
5
|
+
GitBranch,
|
|
6
|
+
GitCommitHorizontal,
|
|
7
|
+
Settings,
|
|
8
|
+
Search,
|
|
9
|
+
FileCode,
|
|
10
|
+
} from "lucide-react";
|
|
11
|
+
import { useTabStore, type TabType } from "@/stores/tab-store";
|
|
12
|
+
import { useProjectStore } from "@/stores/project-store";
|
|
13
|
+
import { useFileStore, type FileNode } from "@/stores/file-store";
|
|
14
|
+
|
|
15
|
+
interface CommandItem {
|
|
16
|
+
id: string;
|
|
17
|
+
label: string;
|
|
18
|
+
hint?: string;
|
|
19
|
+
icon: React.ElementType;
|
|
20
|
+
action: () => void;
|
|
21
|
+
keywords?: string;
|
|
22
|
+
group: "action" | "file";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Recursively flatten file tree into file-only list */
|
|
26
|
+
function flattenFiles(nodes: FileNode[], prefix = ""): { name: string; path: string }[] {
|
|
27
|
+
const result: { name: string; path: string }[] = [];
|
|
28
|
+
for (const node of nodes) {
|
|
29
|
+
if (node.type === "file") {
|
|
30
|
+
result.push({ name: node.name, path: node.path });
|
|
31
|
+
}
|
|
32
|
+
if (node.children) {
|
|
33
|
+
result.push(...flattenFiles(node.children, node.path));
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return result;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function CommandPalette({ open, onClose }: { open: boolean; onClose: () => void }) {
|
|
40
|
+
const [query, setQuery] = useState("");
|
|
41
|
+
const [selectedIdx, setSelectedIdx] = useState(0);
|
|
42
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
43
|
+
const listRef = useRef<HTMLDivElement>(null);
|
|
44
|
+
|
|
45
|
+
const openTab = useTabStore((s) => s.openTab);
|
|
46
|
+
const activeProject = useProjectStore((s) => s.activeProject);
|
|
47
|
+
const fileTree = useFileStore((s) => s.tree);
|
|
48
|
+
|
|
49
|
+
// Action commands
|
|
50
|
+
const actionCommands = useMemo<CommandItem[]>(() => {
|
|
51
|
+
const projectId = activeProject?.name ?? null;
|
|
52
|
+
const meta = activeProject ? { projectName: activeProject.name } : undefined;
|
|
53
|
+
|
|
54
|
+
const openNewTab = (type: TabType, title: string) => () => {
|
|
55
|
+
openTab({ type, title, projectId, metadata: meta, closable: true });
|
|
56
|
+
onClose();
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
return [
|
|
60
|
+
{ id: "terminal", label: "New Terminal", icon: Terminal, action: openNewTab("terminal", "Terminal"), keywords: "bash shell console", group: "action" },
|
|
61
|
+
{ id: "chat", label: "New AI Chat", icon: MessageSquare, action: openNewTab("chat", "AI Chat"), keywords: "ai assistant claude", group: "action" },
|
|
62
|
+
{ id: "git-graph", label: "Git Graph", icon: GitBranch, action: openNewTab("git-graph", "Git Graph"), keywords: "branch history log", group: "action" },
|
|
63
|
+
{ id: "git-status", label: "Git Status", icon: GitCommitHorizontal, action: openNewTab("git-status", "Git Status"), keywords: "changes diff staged", group: "action" },
|
|
64
|
+
{ id: "settings", label: "Settings", icon: Settings, action: openNewTab("settings", "Settings"), keywords: "config preferences", group: "action" },
|
|
65
|
+
];
|
|
66
|
+
}, [activeProject, openTab, onClose]);
|
|
67
|
+
|
|
68
|
+
// File commands — derived from file store tree
|
|
69
|
+
const fileCommands = useMemo<CommandItem[]>(() => {
|
|
70
|
+
const projectId = activeProject?.name ?? null;
|
|
71
|
+
const meta = activeProject ? { projectName: activeProject.name } : undefined;
|
|
72
|
+
const files = flattenFiles(fileTree);
|
|
73
|
+
|
|
74
|
+
return files.map((f) => ({
|
|
75
|
+
id: `file:${f.path}`,
|
|
76
|
+
label: f.name,
|
|
77
|
+
hint: f.path,
|
|
78
|
+
icon: FileCode,
|
|
79
|
+
group: "file" as const,
|
|
80
|
+
keywords: f.path,
|
|
81
|
+
action: () => {
|
|
82
|
+
openTab({
|
|
83
|
+
type: "editor",
|
|
84
|
+
title: f.name,
|
|
85
|
+
projectId,
|
|
86
|
+
metadata: { ...meta, filePath: f.path },
|
|
87
|
+
closable: true,
|
|
88
|
+
});
|
|
89
|
+
onClose();
|
|
90
|
+
},
|
|
91
|
+
}));
|
|
92
|
+
}, [fileTree, activeProject, openTab, onClose]);
|
|
93
|
+
|
|
94
|
+
const allCommands = useMemo(() => [...actionCommands, ...fileCommands], [actionCommands, fileCommands]);
|
|
95
|
+
|
|
96
|
+
const filtered = useMemo(() => {
|
|
97
|
+
if (!query.trim()) return actionCommands; // show only actions when empty
|
|
98
|
+
const q = query.toLowerCase();
|
|
99
|
+
// Fuzzy-ish: every character of query must appear in order
|
|
100
|
+
const matchesFuzzy = (text: string) => {
|
|
101
|
+
let ti = 0;
|
|
102
|
+
for (let qi = 0; qi < q.length; qi++) {
|
|
103
|
+
ti = text.indexOf(q[qi]!, ti);
|
|
104
|
+
if (ti === -1) return false;
|
|
105
|
+
ti++;
|
|
106
|
+
}
|
|
107
|
+
return true;
|
|
108
|
+
};
|
|
109
|
+
return allCommands.filter(
|
|
110
|
+
(c) => matchesFuzzy(c.label.toLowerCase()) || (c.keywords && matchesFuzzy(c.keywords.toLowerCase())),
|
|
111
|
+
);
|
|
112
|
+
}, [allCommands, actionCommands, query]);
|
|
113
|
+
|
|
114
|
+
// Reset state when opening
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
if (open) {
|
|
117
|
+
setQuery("");
|
|
118
|
+
setSelectedIdx(0);
|
|
119
|
+
requestAnimationFrame(() => inputRef.current?.focus());
|
|
120
|
+
}
|
|
121
|
+
}, [open]);
|
|
122
|
+
|
|
123
|
+
// Clamp selected index when filter changes
|
|
124
|
+
useEffect(() => {
|
|
125
|
+
setSelectedIdx((prev) => Math.min(prev, Math.max(filtered.length - 1, 0)));
|
|
126
|
+
}, [filtered.length]);
|
|
127
|
+
|
|
128
|
+
// Scroll selected item into view
|
|
129
|
+
useEffect(() => {
|
|
130
|
+
const list = listRef.current;
|
|
131
|
+
if (!list) return;
|
|
132
|
+
const el = list.children[selectedIdx] as HTMLElement | undefined;
|
|
133
|
+
el?.scrollIntoView({ block: "nearest" });
|
|
134
|
+
}, [selectedIdx]);
|
|
135
|
+
|
|
136
|
+
function handleKeyDown(e: React.KeyboardEvent) {
|
|
137
|
+
switch (e.key) {
|
|
138
|
+
case "ArrowDown":
|
|
139
|
+
e.preventDefault();
|
|
140
|
+
setSelectedIdx((i) => (i + 1) % filtered.length);
|
|
141
|
+
break;
|
|
142
|
+
case "ArrowUp":
|
|
143
|
+
e.preventDefault();
|
|
144
|
+
setSelectedIdx((i) => (i - 1 + filtered.length) % filtered.length);
|
|
145
|
+
break;
|
|
146
|
+
case "Enter":
|
|
147
|
+
e.preventDefault();
|
|
148
|
+
filtered[selectedIdx]?.action();
|
|
149
|
+
break;
|
|
150
|
+
case "Escape":
|
|
151
|
+
e.preventDefault();
|
|
152
|
+
onClose();
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (!open) return null;
|
|
158
|
+
|
|
159
|
+
return (
|
|
160
|
+
<div className="fixed inset-0 z-50 flex items-start justify-center pt-[20vh]" onClick={onClose}>
|
|
161
|
+
<div className="fixed inset-0 bg-black/50" />
|
|
162
|
+
<div
|
|
163
|
+
className="relative z-10 w-full max-w-md rounded-lg border border-border bg-background shadow-2xl overflow-hidden"
|
|
164
|
+
onClick={(e) => e.stopPropagation()}
|
|
165
|
+
onKeyDown={handleKeyDown}
|
|
166
|
+
>
|
|
167
|
+
{/* Search input */}
|
|
168
|
+
<div className="flex items-center gap-2 border-b border-border px-3 py-2.5">
|
|
169
|
+
<Search className="size-4 text-text-subtle shrink-0" />
|
|
170
|
+
<input
|
|
171
|
+
ref={inputRef}
|
|
172
|
+
type="text"
|
|
173
|
+
value={query}
|
|
174
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
175
|
+
placeholder="Search actions & files..."
|
|
176
|
+
className="flex-1 bg-transparent text-sm text-text-primary outline-none placeholder:text-text-subtle"
|
|
177
|
+
/>
|
|
178
|
+
<kbd className="hidden sm:inline-flex items-center rounded border border-border bg-surface px-1.5 py-0.5 text-[10px] text-text-subtle font-mono">
|
|
179
|
+
ESC
|
|
180
|
+
</kbd>
|
|
181
|
+
</div>
|
|
182
|
+
|
|
183
|
+
{/* Results */}
|
|
184
|
+
<div ref={listRef} className="max-h-72 overflow-y-auto py-1">
|
|
185
|
+
{filtered.length === 0 ? (
|
|
186
|
+
<p className="px-3 py-4 text-sm text-text-subtle text-center">No results</p>
|
|
187
|
+
) : (
|
|
188
|
+
filtered.map((cmd, i) => {
|
|
189
|
+
const Icon = cmd.icon;
|
|
190
|
+
return (
|
|
191
|
+
<button
|
|
192
|
+
key={cmd.id}
|
|
193
|
+
onClick={cmd.action}
|
|
194
|
+
className={`flex items-center gap-3 w-full px-3 py-2 text-sm text-left transition-colors ${
|
|
195
|
+
i === selectedIdx
|
|
196
|
+
? "bg-accent/15 text-text-primary"
|
|
197
|
+
: "text-text-secondary hover:bg-surface-elevated"
|
|
198
|
+
}`}
|
|
199
|
+
>
|
|
200
|
+
<Icon className="size-4 shrink-0" />
|
|
201
|
+
<span className="truncate">{cmd.label}</span>
|
|
202
|
+
{cmd.hint && (
|
|
203
|
+
<span className="ml-auto text-xs text-text-subtle truncate max-w-[200px]">
|
|
204
|
+
{cmd.hint}
|
|
205
|
+
</span>
|
|
206
|
+
)}
|
|
207
|
+
</button>
|
|
208
|
+
);
|
|
209
|
+
})
|
|
210
|
+
)}
|
|
211
|
+
</div>
|
|
212
|
+
</div>
|
|
213
|
+
</div>
|
|
214
|
+
);
|
|
215
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { useState, useMemo } from "react";
|
|
1
2
|
import {
|
|
2
3
|
FolderOpen,
|
|
3
4
|
Terminal,
|
|
@@ -8,8 +9,12 @@ import {
|
|
|
8
9
|
Settings,
|
|
9
10
|
X,
|
|
10
11
|
FileCode,
|
|
12
|
+
ChevronDown,
|
|
13
|
+
Check,
|
|
14
|
+
Plus,
|
|
15
|
+
Search,
|
|
11
16
|
} from "lucide-react";
|
|
12
|
-
import { useProjectStore } from "@/stores/project-store";
|
|
17
|
+
import { useProjectStore, sortByRecent } from "@/stores/project-store";
|
|
13
18
|
import { useTabStore, type TabType } from "@/stores/tab-store";
|
|
14
19
|
import { cn } from "@/lib/utils";
|
|
15
20
|
import { Separator } from "@/components/ui/separator";
|
|
@@ -32,7 +37,6 @@ const TAB_ICONS: Record<TabType, React.ElementType> = {
|
|
|
32
37
|
};
|
|
33
38
|
|
|
34
39
|
const NEW_TAB_OPTIONS: { type: TabType; label: string }[] = [
|
|
35
|
-
{ type: "projects", label: "Projects" },
|
|
36
40
|
{ type: "terminal", label: "Terminal" },
|
|
37
41
|
{ type: "chat", label: "AI Chat" },
|
|
38
42
|
{ type: "git-status", label: "Git Status" },
|
|
@@ -40,14 +44,26 @@ const NEW_TAB_OPTIONS: { type: TabType; label: string }[] = [
|
|
|
40
44
|
{ type: "settings", label: "Settings" },
|
|
41
45
|
];
|
|
42
46
|
|
|
43
|
-
/**
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
* Bottom: new tab options.
|
|
47
|
-
*/
|
|
47
|
+
/** Max projects shown before needing to search (mobile — larger items) */
|
|
48
|
+
const MAX_VISIBLE_MOBILE = 5;
|
|
49
|
+
|
|
48
50
|
export function MobileDrawer({ isOpen, onClose }: MobileDrawerProps) {
|
|
49
|
-
const activeProject = useProjectStore(
|
|
51
|
+
const { projects, activeProject, setActiveProject } = useProjectStore();
|
|
50
52
|
const openTab = useTabStore((s) => s.openTab);
|
|
53
|
+
const [projectPickerOpen, setProjectPickerOpen] = useState(false);
|
|
54
|
+
const [query, setQuery] = useState("");
|
|
55
|
+
|
|
56
|
+
const sorted = useMemo(() => sortByRecent(projects), [projects]);
|
|
57
|
+
|
|
58
|
+
const filtered = useMemo(() => {
|
|
59
|
+
if (!query.trim()) return sorted.slice(0, MAX_VISIBLE_MOBILE);
|
|
60
|
+
const q = query.toLowerCase();
|
|
61
|
+
return sorted.filter(
|
|
62
|
+
(p) => p.name.toLowerCase().includes(q) || p.path.toLowerCase().includes(q),
|
|
63
|
+
);
|
|
64
|
+
}, [sorted, query]);
|
|
65
|
+
|
|
66
|
+
const showSearch = projects.length > MAX_VISIBLE_MOBILE || query.length > 0;
|
|
51
67
|
|
|
52
68
|
function handleNewTab(type: TabType) {
|
|
53
69
|
const needsProject =
|
|
@@ -56,17 +72,26 @@ export function MobileDrawer({ isOpen, onClose }: MobileDrawerProps) {
|
|
|
56
72
|
? { projectName: activeProject?.name }
|
|
57
73
|
: undefined;
|
|
58
74
|
const label = NEW_TAB_OPTIONS.find((o) => o.type === type)?.label ?? type;
|
|
59
|
-
openTab({ type, title: label, metadata, projectId: activeProject?.name ?? null, closable:
|
|
75
|
+
openTab({ type, title: label, metadata, projectId: activeProject?.name ?? null, closable: true });
|
|
60
76
|
onClose();
|
|
61
77
|
}
|
|
62
78
|
|
|
79
|
+
function handleSelectProject(project: typeof projects[number]) {
|
|
80
|
+
setActiveProject(project);
|
|
81
|
+
setProjectPickerOpen(false);
|
|
82
|
+
setQuery("");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function handleTogglePicker() {
|
|
86
|
+
setProjectPickerOpen((v) => !v);
|
|
87
|
+
setQuery("");
|
|
88
|
+
}
|
|
89
|
+
|
|
63
90
|
return (
|
|
64
91
|
<div
|
|
65
92
|
className={cn(
|
|
66
93
|
"fixed inset-0 z-50 md:hidden transition-opacity duration-200",
|
|
67
|
-
isOpen
|
|
68
|
-
? "opacity-100"
|
|
69
|
-
: "opacity-0 pointer-events-none",
|
|
94
|
+
isOpen ? "opacity-100" : "opacity-0 pointer-events-none",
|
|
70
95
|
)}
|
|
71
96
|
>
|
|
72
97
|
{/* Backdrop */}
|
|
@@ -84,14 +109,9 @@ export function MobileDrawer({ isOpen, onClose }: MobileDrawerProps) {
|
|
|
84
109
|
isOpen ? "translate-x-0" : "-translate-x-full",
|
|
85
110
|
)}
|
|
86
111
|
>
|
|
87
|
-
{/* Header */}
|
|
88
|
-
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
|
89
|
-
<
|
|
90
|
-
<FolderOpen className="size-4 text-primary" />
|
|
91
|
-
<span className="text-sm font-semibold truncate">
|
|
92
|
-
{activeProject?.name ?? "PPM"}
|
|
93
|
-
</span>
|
|
94
|
-
</div>
|
|
112
|
+
{/* Header — logo + close */}
|
|
113
|
+
<div className="flex items-center justify-between px-4 py-3 border-b border-border shrink-0">
|
|
114
|
+
<span className="text-sm font-bold text-primary tracking-tight">PPM</span>
|
|
95
115
|
<button
|
|
96
116
|
onClick={onClose}
|
|
97
117
|
className="flex items-center justify-center size-8 rounded-md hover:bg-surface-elevated transition-colors"
|
|
@@ -100,36 +120,117 @@ export function MobileDrawer({ isOpen, onClose }: MobileDrawerProps) {
|
|
|
100
120
|
</button>
|
|
101
121
|
</div>
|
|
102
122
|
|
|
103
|
-
{/* File tree — takes remaining space */}
|
|
123
|
+
{/* File tree — scrollable, takes remaining space */}
|
|
104
124
|
<div className="flex-1 overflow-y-auto">
|
|
105
125
|
{activeProject ? (
|
|
106
126
|
<FileTree onFileOpen={onClose} />
|
|
107
127
|
) : (
|
|
108
|
-
<p className="px-4 py-
|
|
109
|
-
|
|
128
|
+
<p className="px-4 py-6 text-xs text-text-secondary text-center">
|
|
129
|
+
Select a project below
|
|
110
130
|
</p>
|
|
111
131
|
)}
|
|
112
132
|
</div>
|
|
113
133
|
|
|
114
|
-
{/*
|
|
115
|
-
<
|
|
116
|
-
|
|
117
|
-
<
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
134
|
+
{/* Bottom section — actions within thumb reach */}
|
|
135
|
+
<div className="shrink-0 border-t border-border">
|
|
136
|
+
{/* New tab actions */}
|
|
137
|
+
<div className="px-2 py-2 space-y-0.5">
|
|
138
|
+
{NEW_TAB_OPTIONS.map((opt) => {
|
|
139
|
+
const Icon = TAB_ICONS[opt.type];
|
|
140
|
+
return (
|
|
141
|
+
<button
|
|
142
|
+
key={opt.type}
|
|
143
|
+
onClick={() => handleNewTab(opt.type)}
|
|
144
|
+
className="w-full flex items-center gap-2 px-3 py-2 rounded-md text-sm text-text-secondary hover:bg-surface-elevated hover:text-foreground transition-colors min-h-[40px]"
|
|
145
|
+
>
|
|
146
|
+
<Icon className="size-4 shrink-0" />
|
|
147
|
+
<span>{opt.label}</span>
|
|
148
|
+
</button>
|
|
149
|
+
);
|
|
150
|
+
})}
|
|
151
|
+
</div>
|
|
152
|
+
|
|
153
|
+
<Separator />
|
|
154
|
+
|
|
155
|
+
{/* Project switcher — at very bottom for easy thumb access */}
|
|
156
|
+
<div className="relative">
|
|
157
|
+
<button
|
|
158
|
+
onClick={handleTogglePicker}
|
|
159
|
+
className="w-full flex items-center gap-2 px-4 py-3 text-left hover:bg-surface-elevated transition-colors"
|
|
160
|
+
>
|
|
161
|
+
<FolderOpen className="size-4 text-primary shrink-0" />
|
|
162
|
+
<span className="text-sm font-medium truncate flex-1">
|
|
163
|
+
{activeProject?.name ?? "Select Project"}
|
|
164
|
+
</span>
|
|
165
|
+
<ChevronDown className={cn(
|
|
166
|
+
"size-3.5 text-text-subtle shrink-0 transition-transform",
|
|
167
|
+
projectPickerOpen && "rotate-180",
|
|
168
|
+
)} />
|
|
169
|
+
</button>
|
|
170
|
+
|
|
171
|
+
{/* Project list popover — opens upward */}
|
|
172
|
+
{projectPickerOpen && (
|
|
173
|
+
<div className="absolute bottom-full left-0 right-0 bg-background border border-border rounded-t-lg shadow-lg overflow-hidden">
|
|
174
|
+
{/* Search */}
|
|
175
|
+
{showSearch && (
|
|
176
|
+
<div className="flex items-center gap-2 px-3 py-2 border-b border-border">
|
|
177
|
+
<Search className="size-3.5 text-text-subtle shrink-0" />
|
|
178
|
+
<input
|
|
179
|
+
type="text"
|
|
180
|
+
value={query}
|
|
181
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
182
|
+
placeholder="Search projects..."
|
|
183
|
+
className="flex-1 bg-transparent text-sm outline-none placeholder:text-text-subtle text-text-primary"
|
|
184
|
+
autoFocus
|
|
185
|
+
/>
|
|
186
|
+
</div>
|
|
187
|
+
)}
|
|
188
|
+
|
|
189
|
+
{/* Project list */}
|
|
190
|
+
<div className="max-h-56 overflow-y-auto">
|
|
191
|
+
{filtered.map((project) => (
|
|
192
|
+
<button
|
|
193
|
+
key={project.name}
|
|
194
|
+
onClick={() => handleSelectProject(project)}
|
|
195
|
+
className={cn(
|
|
196
|
+
"w-full flex items-center gap-2.5 px-4 py-2 text-left transition-colors",
|
|
197
|
+
activeProject?.name === project.name
|
|
198
|
+
? "bg-accent/10 text-text-primary"
|
|
199
|
+
: "text-text-secondary hover:bg-surface-elevated",
|
|
200
|
+
)}
|
|
201
|
+
>
|
|
202
|
+
<FolderOpen className="size-4 shrink-0" />
|
|
203
|
+
<div className="flex-1 min-w-0">
|
|
204
|
+
<p className="text-sm font-medium truncate">{project.name}</p>
|
|
205
|
+
<p className="text-xs text-text-subtle truncate">{project.path}</p>
|
|
206
|
+
</div>
|
|
207
|
+
{activeProject?.name === project.name && (
|
|
208
|
+
<Check className="size-4 text-primary shrink-0" />
|
|
209
|
+
)}
|
|
210
|
+
</button>
|
|
211
|
+
))}
|
|
212
|
+
{filtered.length === 0 && (
|
|
213
|
+
<p className="px-4 py-3 text-xs text-text-subtle text-center">
|
|
214
|
+
{query ? "No matches" : "No projects"}
|
|
215
|
+
</p>
|
|
216
|
+
)}
|
|
217
|
+
</div>
|
|
218
|
+
|
|
219
|
+
{/* Add project */}
|
|
220
|
+
<button
|
|
221
|
+
onClick={() => {
|
|
222
|
+
setProjectPickerOpen(false);
|
|
223
|
+
openTab({ type: "projects", title: "Projects", projectId: null, closable: true });
|
|
224
|
+
onClose();
|
|
225
|
+
}}
|
|
226
|
+
className="w-full flex items-center gap-2 px-4 py-2.5 text-left text-sm text-text-secondary hover:bg-surface-elevated border-t border-border"
|
|
227
|
+
>
|
|
228
|
+
<Plus className="size-4 shrink-0" />
|
|
229
|
+
<span>Add Project...</span>
|
|
230
|
+
</button>
|
|
231
|
+
</div>
|
|
232
|
+
)}
|
|
233
|
+
</div>
|
|
133
234
|
</div>
|
|
134
235
|
</div>
|
|
135
236
|
</div>
|