@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.
Files changed (102) hide show
  1. package/CLAUDE.md +45 -0
  2. package/bun.lock +55 -0
  3. package/dist/ppm +0 -0
  4. package/dist/web/assets/api-client-BgVufYKf.js +1 -0
  5. package/dist/web/assets/arrow-up-from-line-DjfWTP75.js +1 -0
  6. package/dist/web/assets/button-KIZetva8.js +41 -0
  7. package/dist/web/assets/chat-tab-D7dR7kbZ.js +6 -0
  8. package/dist/web/assets/code-editor-r8P6Gk4M.js +2 -0
  9. package/dist/web/assets/copy-B-kLwqzg.js +1 -0
  10. package/dist/web/assets/dialog-D8ulRTfX.js +5 -0
  11. package/dist/web/assets/diff-viewer-vSvrem_i.js +4 -0
  12. package/dist/web/assets/dist-C4W3AGh3.js +1 -0
  13. package/dist/web/assets/dist-PA84y4Ga.js +1 -0
  14. package/dist/web/assets/external-link-Dim3NH6h.js +1 -0
  15. package/dist/web/assets/git-graph-Cn-s1k0-.js +1 -0
  16. package/dist/web/assets/git-status-panel-QjAQzNAi.js +1 -0
  17. package/dist/web/assets/index-DUBI96T5.css +2 -0
  18. package/dist/web/assets/index-nk1dAWff.js +10 -0
  19. package/dist/web/assets/{jsx-runtime-BnxRlLMJ.js → jsx-runtime-BFALxl05.js} +1 -1
  20. package/dist/web/assets/marked.esm-Cv8mjgnt.js +59 -0
  21. package/dist/web/assets/project-list-DqiatpaH.js +1 -0
  22. package/dist/web/assets/{react-Uzd0zARU.js → react-BSLFEYu8.js} +1 -1
  23. package/dist/web/assets/refresh-cw-DJSjl6Ev.js +1 -0
  24. package/dist/web/assets/settings-tab-iCGeFFdt.js +1 -0
  25. package/dist/web/assets/terminal-tab-DDf6S-Tu.js +36 -0
  26. package/dist/web/assets/trash-2-CjahwKg8.js +1 -0
  27. package/dist/web/assets/x-BxhOxZ5p.js +1 -0
  28. package/dist/web/index.html +11 -10
  29. package/dist/web/sw.js +1 -1
  30. package/docs/claude-agent-sdk-reference.md +780 -0
  31. package/docs/code-standards.md +74 -0
  32. package/docs/codebase-summary.md +22 -20
  33. package/docs/deployment-guide.md +81 -11
  34. package/docs/lessons-learned.md +58 -0
  35. package/docs/project-overview-pdr.md +62 -2
  36. package/docs/system-architecture.md +102 -10
  37. package/package.json +4 -1
  38. package/schemas/ppm-config.schema.json +87 -0
  39. package/src/cli/commands/init.ts +186 -43
  40. package/src/cli/commands/status.ts +73 -0
  41. package/src/cli/commands/stop.ts +24 -10
  42. package/src/index.ts +28 -5
  43. package/src/providers/claude-agent-sdk.ts +84 -3
  44. package/src/providers/registry.ts +0 -2
  45. package/src/server/index.ts +106 -15
  46. package/src/server/routes/settings.ts +70 -0
  47. package/src/server/ws/chat.ts +8 -6
  48. package/src/services/cloudflared.service.ts +99 -0
  49. package/src/services/git.service.ts +23 -1
  50. package/src/services/tunnel.service.ts +100 -0
  51. package/src/types/chat.ts +8 -1
  52. package/src/types/config.ts +50 -3
  53. package/src/web/app.tsx +10 -2
  54. package/src/web/components/auth/login-screen.tsx +1 -1
  55. package/src/web/components/chat/message-input.tsx +1 -1
  56. package/src/web/components/chat/message-list.tsx +112 -251
  57. package/src/web/components/chat/tool-cards.tsx +411 -0
  58. package/src/web/components/editor/code-editor.tsx +80 -20
  59. package/src/web/components/editor/diff-viewer.tsx +72 -7
  60. package/src/web/components/git/git-graph.tsx +3 -0
  61. package/src/web/components/git/git-status-panel.tsx +50 -1
  62. package/src/web/components/layout/command-palette.tsx +215 -0
  63. package/src/web/components/layout/mobile-drawer.tsx +143 -42
  64. package/src/web/components/layout/sidebar.tsx +103 -67
  65. package/src/web/components/layout/tab-bar.tsx +1 -2
  66. package/src/web/components/settings/ai-settings-section.tsx +166 -0
  67. package/src/web/components/settings/settings-tab.tsx +5 -0
  68. package/src/web/components/terminal/terminal-tab.tsx +45 -22
  69. package/src/web/components/ui/input.tsx +4 -3
  70. package/src/web/components/ui/label.tsx +24 -0
  71. package/src/web/components/ui/select.tsx +188 -0
  72. package/src/web/hooks/use-global-keybindings.ts +56 -0
  73. package/src/web/hooks/use-terminal.ts +14 -1
  74. package/src/web/lib/api-settings.ts +24 -0
  75. package/src/web/stores/project-store.ts +47 -2
  76. package/src/web/stores/tab-store.ts +1 -1
  77. package/src/web/styles/globals.css +20 -6
  78. package/test-tool.mjs +41 -0
  79. package/dist/web/assets/api-client-Bnf9LAt4.js +0 -1
  80. package/dist/web/assets/arrow-up-from-line-BXL5dtbG.js +0 -1
  81. package/dist/web/assets/button-DxRZgE8F.js +0 -1
  82. package/dist/web/assets/chat-tab-p2mwkdec.js +0 -61
  83. package/dist/web/assets/code-editor-vMRyRKV3.js +0 -2
  84. package/dist/web/assets/createLucideIcon-Dy1wlrF7.js +0 -1
  85. package/dist/web/assets/dialog-Db6prp1p.js +0 -45
  86. package/dist/web/assets/diff-viewer-BdDje3Wr.js +0 -4
  87. package/dist/web/assets/external-link-WSiY-639.js +0 -1
  88. package/dist/web/assets/git-graph-B-qwuFoO.js +0 -1
  89. package/dist/web/assets/git-status-panel-NkZFb5v1.js +0 -1
  90. package/dist/web/assets/index-BHEFCU01.js +0 -10
  91. package/dist/web/assets/index-DYd_2slk.css +0 -2
  92. package/dist/web/assets/project-list-NkR7IHT5.js +0 -1
  93. package/dist/web/assets/refresh-cw-DtopuYJf.js +0 -1
  94. package/dist/web/assets/settings-tab-DKx0s3Q1.js +0 -1
  95. package/dist/web/assets/terminal-tab-DHwn2LMT.js +0 -36
  96. package/dist/web/assets/trash-2-CHLebaNh.js +0 -1
  97. package/dist/web/assets/x-BISR7bpK.js +0 -1
  98. package/src/providers/claude-binary-finder.ts +0 -256
  99. package/src/providers/claude-code-cli.ts +0 -413
  100. package/src/providers/claude-process-registry.ts +0 -106
  101. /package/dist/web/assets/{dist-CSp7ir0r.js → dist-CBiGQxfr.js} +0 -0
  102. /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-2 py-1.5 text-sm bg-muted/50 border rounded resize-none focus:outline-none focus:ring-1 focus:ring-ring"
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
- * Mobile drawer overlay — opens from bottom-left menu button.
45
- * Top: file tree of current project.
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((s) => s.activeProject);
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: type !== "projects" });
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
- <div className="flex items-center gap-2">
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-3 text-xs text-text-secondary">
109
- No project selected.
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
- {/* New tab options pinned at bottom */}
115
- <Separator />
116
- <div className="px-2 py-2 space-y-0.5">
117
- <p className="px-2 pb-1 text-xs font-semibold text-text-secondary uppercase tracking-wider">
118
- New Tab
119
- </p>
120
- {NEW_TAB_OPTIONS.map((opt) => {
121
- const Icon = TAB_ICONS[opt.type];
122
- return (
123
- <button
124
- key={opt.type}
125
- onClick={() => handleNewTab(opt.type)}
126
- 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]"
127
- >
128
- <Icon className="size-4 shrink-0" />
129
- <span>{opt.label}</span>
130
- </button>
131
- );
132
- })}
134
+ {/* Bottom sectionactions 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>