@hienlh/ppm 0.12.7 → 0.12.9

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 (70) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/web/assets/{ai-settings-section-BHdBBJtS.js → ai-settings-section-QE6nBNgN.js} +1 -1
  3. package/dist/web/assets/api-client-Dvzcc_EO.js +1 -0
  4. package/dist/web/assets/{api-settings-ByUGHhTB.js → api-settings-DAk7D-NP.js} +1 -1
  5. package/dist/web/assets/{audio-preview-A6ScJemm.js → audio-preview-DnQmf9fu.js} +1 -1
  6. package/dist/web/assets/chat-tab-Cf6T3mGO.js +12 -0
  7. package/dist/web/assets/code-editor-B-lU1fz3.js +8 -0
  8. package/dist/web/assets/{conflict-editor-DQt8Bap3.js → conflict-editor-BYzf3LuW.js} +1 -1
  9. package/dist/web/assets/{database-viewer-C1k-aq-e.js → database-viewer-DjvnIn8p.js} +2 -2
  10. package/dist/web/assets/{diff-viewer-TowzH722.js → diff-viewer-CP2jcR5J.js} +1 -1
  11. package/dist/web/assets/{extension-webview-Cn1x5C5F.js → extension-webview-4xMREn_x.js} +1 -1
  12. package/dist/web/assets/file-store-BrbCNyLm.js +1 -0
  13. package/dist/web/assets/{image-preview-MGnGKiYs.js → image-preview-CkS2PVdQ.js} +1 -1
  14. package/dist/web/assets/index-BTjuH4fn.css +2 -0
  15. package/dist/web/assets/index-FGlF8IWZ.js +23 -0
  16. package/dist/web/assets/{keybindings-store-CThBg3hS.js → keybindings-store-B-zET-0o.js} +1 -1
  17. package/dist/web/assets/keybindings-store-DaBV6qhz.js +1 -0
  18. package/dist/web/assets/{markdown-renderer-DSINJjCx.js → markdown-renderer-Bj2B05Km.js} +1 -1
  19. package/dist/web/assets/{pdf-preview-BiI5Qihn.js → pdf-preview-CCyw5cuH.js} +1 -1
  20. package/dist/web/assets/{port-forwarding-tab-jjdgxhoi.js → port-forwarding-tab-Cebb5Eix.js} +1 -1
  21. package/dist/web/assets/{postgres-viewer-BwXJ-fGk.js → postgres-viewer-BrOiliEv.js} +2 -2
  22. package/dist/web/assets/{settings-store-BMZgnYTp.js → settings-store-BLLR7ed8.js} +2 -2
  23. package/dist/web/assets/settings-tab-D0XjupJm.js +1 -0
  24. package/dist/web/assets/{sql-query-editor-BSHd21AE.js → sql-query-editor-CVAnRFbi.js} +1 -1
  25. package/dist/web/assets/{sqlite-viewer-BPywcOES.js → sqlite-viewer-OEVq_-Po.js} +1 -1
  26. package/dist/web/assets/{terminal-tab-Civ2Yhce.js → terminal-tab-MjmJaQyA.js} +1 -1
  27. package/dist/web/assets/{use-blob-url-BU9hYOj9.js → use-blob-url-e9uTXjv5.js} +1 -1
  28. package/dist/web/assets/{use-monaco-theme-CXs7t0_G.js → use-monaco-theme-BkZDwoVd.js} +1 -1
  29. package/dist/web/assets/{video-preview-Db5TkPSt.js → video-preview-B819qvlp.js} +1 -1
  30. package/dist/web/index.html +8 -8
  31. package/dist/web/sw.js +1 -1
  32. package/docs/journals/260421-lazy-load-file-tree-palette-index.md +125 -0
  33. package/docs/project-changelog.md +13 -1
  34. package/docs/system-architecture.md +79 -1
  35. package/package.json +1 -1
  36. package/src/providers/claude-agent-sdk.ts +23 -0
  37. package/src/server/index.ts +1 -1
  38. package/src/server/routes/files.ts +40 -2
  39. package/src/server/routes/projects.ts +53 -0
  40. package/src/server/routes/settings.ts +50 -1
  41. package/src/services/config.service.ts +41 -0
  42. package/src/services/db.service.ts +57 -1
  43. package/src/services/file-filter.service.ts +121 -0
  44. package/src/services/file-list-index.service.ts +170 -0
  45. package/src/services/file-watcher.service.ts +8 -4
  46. package/src/services/file.service.ts +55 -53
  47. package/src/services/upgrade.service.ts +2 -2
  48. package/src/types/chat.ts +2 -1
  49. package/src/types/project.ts +31 -0
  50. package/src/web/components/chat/file-picker.tsx +0 -13
  51. package/src/web/components/chat/message-input.tsx +11 -14
  52. package/src/web/components/chat/tool-cards.tsx +4 -2
  53. package/src/web/components/explorer/file-tree.tsx +91 -26
  54. package/src/web/components/layout/command-palette.tsx +26 -3
  55. package/src/web/components/settings/files-settings-section.tsx +230 -0
  56. package/src/web/components/settings/glob-list-editor.tsx +121 -0
  57. package/src/web/components/settings/settings-tab.tsx +5 -2
  58. package/src/web/lib/api-client.ts +2 -1
  59. package/src/web/lib/api-files-settings.ts +42 -0
  60. package/src/web/stores/file-store.ts +139 -14
  61. package/src/web/stores/file-tree-merge-helpers.ts +44 -0
  62. package/src/web/stores/jira-store.ts +1 -1
  63. package/dist/web/assets/api-client-CwbMRXYl.js +0 -1
  64. package/dist/web/assets/chat-tab--Rc7WIJp.js +0 -12
  65. package/dist/web/assets/code-editor-DZSUYMBx.js +0 -8
  66. package/dist/web/assets/index-BrAupjGV.css +0 -2
  67. package/dist/web/assets/index-gxtJiPiW.js +0 -23
  68. package/dist/web/assets/keybindings-store-BIQHClUy.js +0 -1
  69. package/dist/web/assets/project-store-IB6pAGQh.js +0 -1
  70. package/dist/web/assets/settings-tab-USIB-LOd.js +0 -1
@@ -0,0 +1,121 @@
1
+ /**
2
+ * glob-list-editor.tsx
3
+ * Reusable list editor for glob pattern arrays (filesExclude, searchExclude, etc).
4
+ * Supports add, remove, inline edit, keyboard shortcuts (Enter=add, Backspace on empty=remove).
5
+ */
6
+
7
+ import { useRef } from "react";
8
+ import { Plus, X } from "lucide-react";
9
+ import { Input } from "@/components/ui/input";
10
+ import { Button } from "@/components/ui/button";
11
+
12
+ interface GlobListEditorProps {
13
+ /** Current pattern list */
14
+ value: string[];
15
+ /** Called with updated list on any change */
16
+ onChange: (next: string[]) => void;
17
+ /** Placeholder text for each input row */
18
+ placeholder?: string;
19
+ /** Disabled state */
20
+ disabled?: boolean;
21
+ }
22
+
23
+ /**
24
+ * Vertical list editor for glob patterns.
25
+ * - Each row: Input + Remove button (min 44px touch target)
26
+ * - Footer: "Add pattern" button
27
+ * - Enter on last row adds new; Backspace on empty row removes it
28
+ */
29
+ export function GlobListEditor({
30
+ value,
31
+ onChange,
32
+ placeholder = "e.g. **/*.log",
33
+ disabled = false,
34
+ }: GlobListEditorProps) {
35
+ // Refs for auto-focusing newly added rows
36
+ const rowRefs = useRef<(HTMLInputElement | null)[]>([]);
37
+
38
+ function handleChange(idx: number, text: string) {
39
+ const next = [...value];
40
+ next[idx] = text;
41
+ onChange(next);
42
+ }
43
+
44
+ function handleRemove(idx: number) {
45
+ const next = value.filter((_, i) => i !== idx);
46
+ onChange(next);
47
+ // Focus previous row or add-button area after removal
48
+ setTimeout(() => {
49
+ const prevIdx = Math.max(idx - 1, 0);
50
+ rowRefs.current[prevIdx]?.focus();
51
+ }, 0);
52
+ }
53
+
54
+ function handleAdd() {
55
+ onChange([...value, ""]);
56
+ // Focus the new row after render
57
+ setTimeout(() => {
58
+ rowRefs.current[value.length]?.focus();
59
+ }, 0);
60
+ }
61
+
62
+ function handleKeyDown(idx: number, e: React.KeyboardEvent<HTMLInputElement>) {
63
+ if (e.key === "Enter") {
64
+ e.preventDefault();
65
+ // Only add if current row is non-empty
66
+ if (value[idx]?.trim()) {
67
+ handleAdd();
68
+ }
69
+ } else if (e.key === "Backspace" && value[idx] === "") {
70
+ e.preventDefault();
71
+ handleRemove(idx);
72
+ }
73
+ }
74
+
75
+ return (
76
+ <div className="space-y-1.5">
77
+ {value.length === 0 && (
78
+ <p className="text-[11px] text-muted-foreground py-1">
79
+ No patterns. Click "Add pattern" to start.
80
+ </p>
81
+ )}
82
+
83
+ {value.map((pattern, idx) => (
84
+ <div key={idx} className="flex items-center gap-1.5">
85
+ <Input
86
+ ref={(el) => { rowRefs.current[idx] = el; }}
87
+ value={pattern}
88
+ onChange={(e) => handleChange(idx, e.target.value)}
89
+ onKeyDown={(e) => handleKeyDown(idx, e)}
90
+ placeholder={placeholder}
91
+ disabled={disabled}
92
+ className="h-8 text-xs flex-1 font-mono"
93
+ aria-label={`Pattern ${idx + 1}`}
94
+ />
95
+ {/* Remove button — min 44px touch target via p-2.5 */}
96
+ <button
97
+ type="button"
98
+ onClick={() => handleRemove(idx)}
99
+ disabled={disabled}
100
+ aria-label={`Remove pattern ${idx + 1}`}
101
+ className="shrink-0 flex items-center justify-center p-2.5 rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors disabled:opacity-40 disabled:pointer-events-none"
102
+ >
103
+ <X className="size-3.5" />
104
+ </button>
105
+ </div>
106
+ ))}
107
+
108
+ <Button
109
+ type="button"
110
+ variant="outline"
111
+ size="sm"
112
+ onClick={handleAdd}
113
+ disabled={disabled}
114
+ className="h-8 text-xs gap-1.5 w-full cursor-pointer"
115
+ >
116
+ <Plus className="size-3.5" />
117
+ Add pattern
118
+ </Button>
119
+ </div>
120
+ );
121
+ }
@@ -1,7 +1,7 @@
1
1
  import { useState, useCallback, useRef } from "react";
2
2
  import {
3
3
  Moon, Sun, Monitor, Bell, BellOff, Check, ChevronRight, ArrowLeft,
4
- Bot, BellRing, Keyboard, Globe, Plug, Puzzle, Bug,
4
+ Bot, BellRing, Keyboard, Globe, Plug, Puzzle, Bug, FolderSearch,
5
5
  } from "lucide-react";
6
6
  import { Button } from "@/components/ui/button";
7
7
  import { Input } from "@/components/ui/input";
@@ -18,6 +18,7 @@ import { McpSettingsSection } from "./mcp-settings-section";
18
18
  import { ExtensionManagerSection } from "./extension-manager-section";
19
19
  import { PPMBotSettingsSection } from "./ppmbot-settings-section";
20
20
  import { ChangePasswordSection } from "./change-password-section";
21
+ import { FilesSettingsSection } from "./files-settings-section";
21
22
  import { usePushNotification } from "@/hooks/use-push-notification";
22
23
 
23
24
  const THEME_OPTIONS: { value: Theme; label: string; icon: React.ElementType }[] = [
@@ -30,7 +31,7 @@ const pushSupported = "PushManager" in window && "serviceWorker" in navigator;
30
31
  const isIosNonPwa = /iPhone|iPad/.test(navigator.userAgent) &&
31
32
  !window.matchMedia("(display-mode: standalone)").matches;
32
33
 
33
- type SettingsCategory = "ai" | "notifications" | "clawbot" | "jira" | "proxy" | "shortcuts" | "mcp" | "extensions";
34
+ type SettingsCategory = "ai" | "notifications" | "clawbot" | "jira" | "proxy" | "shortcuts" | "mcp" | "extensions" | "files";
34
35
 
35
36
  const CATEGORIES: { value: SettingsCategory; label: string; subtitle: string; icon: React.ElementType }[] = [
36
37
  { value: "ai", label: "AI Provider", subtitle: "Model, execution mode, limits", icon: Bot },
@@ -41,6 +42,7 @@ const CATEGORIES: { value: SettingsCategory; label: string; subtitle: string; ic
41
42
  { value: "shortcuts", label: "Keyboard Shortcuts", subtitle: "Customize key bindings", icon: Keyboard },
42
43
  { value: "mcp", label: "MCP Servers", subtitle: "Model Context Protocol tools", icon: Plug },
43
44
  { value: "extensions", label: "Extensions", subtitle: "Install and manage extensions", icon: Puzzle },
45
+ { value: "files", label: "File Filters", subtitle: "Exclude patterns, ignore files", icon: FolderSearch },
44
46
  ];
45
47
 
46
48
  export function SettingsTab() {
@@ -97,6 +99,7 @@ export function SettingsTab() {
97
99
  {activeCategory === "shortcuts" && <KeyboardShortcutsSection />}
98
100
  {activeCategory === "mcp" && <McpSettingsSection />}
99
101
  {activeCategory === "extensions" && <ExtensionManagerSection />}
102
+ {activeCategory === "files" && <FilesSettingsSection />}
100
103
  </div>
101
104
  </ScrollArea>
102
105
  </div>
@@ -20,9 +20,10 @@ class ApiClient {
20
20
  }
21
21
 
22
22
  /** Auto-unwraps {ok, data} envelope. Returns T directly. */
23
- async get<T>(path: string): Promise<T> {
23
+ async get<T>(path: string, options?: { signal?: AbortSignal }): Promise<T> {
24
24
  const res = await fetch(`${this.baseUrl}${path}`, {
25
25
  headers: this.headers(),
26
+ signal: options?.signal,
26
27
  });
27
28
  return this.handleResponse<T>(res);
28
29
  }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * api-files-settings.ts
3
+ * API client for global file filter settings and per-project file filter overrides.
4
+ */
5
+
6
+ import { api } from "./api-client";
7
+
8
+ /** Typed file filter config — mirrors server-side FileFilterConfig */
9
+ export interface FileFilterSettings {
10
+ filesExclude: string[];
11
+ searchExclude: string[];
12
+ useIgnoreFiles: boolean;
13
+ }
14
+
15
+ /** Per-project settings envelope — only files field used here */
16
+ export interface ProjectFileSettings {
17
+ files?: Partial<FileFilterSettings>;
18
+ }
19
+
20
+ // ── Global settings ──────────────────────────────────────────────────────────
21
+
22
+ /** GET /api/settings/files — returns global file filter config */
23
+ export function getFilesSettings(): Promise<FileFilterSettings> {
24
+ return api.get<FileFilterSettings>("/api/settings/files");
25
+ }
26
+
27
+ /** PATCH /api/settings/files — partial update to global file filter config */
28
+ export function updateFilesSettings(patch: Partial<FileFilterSettings>): Promise<FileFilterSettings> {
29
+ return api.patch<FileFilterSettings>("/api/settings/files", patch);
30
+ }
31
+
32
+ // ── Per-project settings ─────────────────────────────────────────────────────
33
+
34
+ /** GET /api/projects/:name/settings — returns per-project settings (ProjectSettings shape) */
35
+ export function getProjectSettings(projectName: string): Promise<ProjectFileSettings> {
36
+ return api.get<ProjectFileSettings>(`/api/projects/${encodeURIComponent(projectName)}/settings`);
37
+ }
38
+
39
+ /** PATCH /api/projects/:name/settings — merges patch into project settings */
40
+ export function updateProjectSettings(projectName: string, patch: ProjectFileSettings): Promise<ProjectFileSettings> {
41
+ return api.patch<ProjectFileSettings>(`/api/projects/${encodeURIComponent(projectName)}/settings`, patch);
42
+ }
@@ -1,5 +1,9 @@
1
1
  import { create } from "zustand";
2
2
  import { api, projectUrl } from "@/lib/api-client";
3
+ import type { FileEntry, FileDirEntry } from "../../types/project";
4
+ import { entriesToNodes, mergeChildren } from "./file-tree-merge-helpers";
5
+
6
+ export type { FileEntry };
3
7
 
4
8
  export interface FileNode {
5
9
  name: string;
@@ -8,37 +12,57 @@ export interface FileNode {
8
12
  children?: FileNode[];
9
13
  size?: number;
10
14
  modified?: string;
15
+ /** True if path is matched by a .gitignore rule */
11
16
  ignored?: boolean;
12
17
  }
13
18
 
14
19
  interface FileStore {
15
20
  tree: FileNode[];
21
+ fileIndex: FileEntry[];
16
22
  loading: boolean;
17
23
  error: string | null;
18
24
  expandedPaths: Set<string>;
25
+ loadedPaths: Set<string>;
26
+ /** In-flight AbortControllers keyed by folder path */
27
+ inflight: Map<string, AbortController>;
28
+ indexStatus: "idle" | "loading" | "ready" | "error";
19
29
  selectedFiles: string[];
20
- fetchTree: (projectName: string) => Promise<void>;
21
- toggleExpand: (path: string) => void;
22
- setExpanded: (path: string, expanded: boolean) => void;
23
- toggleFileSelect: (path: string) => void;
24
- clearSelection: () => void;
25
- reset: () => void;
30
+
31
+ loadRoot(projectName: string): Promise<void>;
32
+ loadChildren(projectName: string, folderPath: string): Promise<void>;
33
+ loadIndex(projectName: string): Promise<void>;
34
+ invalidateIndex(): void;
35
+ invalidateFolder(projectName: string, folderPath: string): Promise<void>;
36
+ toggleExpand(projectName: string, path: string): void;
37
+ setExpanded(path: string, expanded: boolean): void;
38
+ toggleFileSelect(path: string): void;
39
+ clearSelection(): void;
40
+ reset(): void;
41
+ /** @deprecated Use loadRoot instead */
42
+ fetchTree(projectName: string): Promise<void>;
26
43
  }
27
44
 
28
45
  export const useFileStore = create<FileStore>((set, get) => ({
29
46
  tree: [],
47
+ fileIndex: [],
30
48
  loading: false,
31
49
  error: null,
32
50
  expandedPaths: new Set<string>(),
51
+ loadedPaths: new Set<string>(),
52
+ inflight: new Map<string, AbortController>(),
53
+ indexStatus: "idle",
33
54
  selectedFiles: [],
34
55
 
35
- fetchTree: async (projectName: string) => {
56
+ loadRoot: async (projectName: string) => {
36
57
  set({ loading: true, error: null });
37
58
  try {
38
- const tree = await api.get<FileNode[]>(
39
- `${projectUrl(projectName)}/files/tree?depth=3`,
59
+ const data = await api.get<FileDirEntry[]>(
60
+ `${projectUrl(projectName)}/files/list?path=`,
40
61
  );
41
- set({ tree, loading: false });
62
+ const rootNodes = entriesToNodes(data, "");
63
+ const loadedPaths = new Set(get().loadedPaths);
64
+ loadedPaths.add(""); // root is loaded
65
+ set({ tree: rootNodes, loading: false, loadedPaths });
42
66
  } catch (err) {
43
67
  set({
44
68
  error: err instanceof Error ? err.message : "Failed to load files",
@@ -47,14 +71,95 @@ export const useFileStore = create<FileStore>((set, get) => ({
47
71
  }
48
72
  },
49
73
 
50
- toggleExpand: (path: string) => {
51
- const expanded = new Set(get().expandedPaths);
74
+ loadChildren: async (projectName: string, folderPath: string) => {
75
+ const state = get();
76
+
77
+ // Idempotent guard — skip if already loaded
78
+ if (state.loadedPaths.has(folderPath)) return;
79
+
80
+ // Abort any existing in-flight request for this path
81
+ const existing = state.inflight.get(folderPath);
82
+ if (existing) existing.abort();
83
+
84
+ const controller = new AbortController();
85
+ const inflight = new Map(state.inflight);
86
+ inflight.set(folderPath, controller);
87
+ set({ inflight });
88
+
89
+ try {
90
+ const encodedPath = encodeURIComponent(folderPath);
91
+ const data = await api.get<FileDirEntry[]>(
92
+ `${projectUrl(projectName)}/files/list?path=${encodedPath}`,
93
+ { signal: controller.signal },
94
+ );
95
+
96
+ // Check if aborted between request start and completion (defense in depth)
97
+ if (controller.signal.aborted) return;
98
+
99
+ const children = entriesToNodes(data, folderPath);
100
+ const currentState = get();
101
+ const newTree = mergeChildren(currentState.tree, folderPath, children);
102
+ const newLoadedPaths = new Set(currentState.loadedPaths);
103
+ newLoadedPaths.add(folderPath);
104
+ const newInflight = new Map(currentState.inflight);
105
+ newInflight.delete(folderPath);
106
+ set({ tree: newTree, loadedPaths: newLoadedPaths, inflight: newInflight });
107
+ } catch (err) {
108
+ if (err instanceof Error && err.name === "AbortError") return;
109
+ // Remove from inflight on error
110
+ const newInflight = new Map(get().inflight);
111
+ newInflight.delete(folderPath);
112
+ set({ inflight: newInflight });
113
+ }
114
+ },
115
+
116
+ loadIndex: async (projectName: string) => {
117
+ set({ indexStatus: "loading" });
118
+ try {
119
+ const data = await api.get<FileEntry[]>(
120
+ `${projectUrl(projectName)}/files/index`,
121
+ );
122
+ set({ fileIndex: data, indexStatus: "ready" });
123
+ } catch {
124
+ set({ indexStatus: "error" });
125
+ }
126
+ },
127
+
128
+ invalidateIndex: () => {
129
+ set({ indexStatus: "idle", fileIndex: [] });
130
+ },
131
+
132
+ invalidateFolder: async (projectName: string, folderPath: string) => {
133
+ const state = get();
134
+
135
+ // Only reload if this folder was previously loaded
136
+ if (!state.loadedPaths.has(folderPath)) return;
137
+
138
+ // Remove from loadedPaths to allow re-fetch
139
+ const newLoadedPaths = new Set(state.loadedPaths);
140
+ newLoadedPaths.delete(folderPath);
141
+ set({ loadedPaths: newLoadedPaths });
142
+
143
+ // Re-fetch if folder is currently expanded (or root)
144
+ if (!folderPath || state.expandedPaths.has(folderPath)) {
145
+ await get().loadChildren(projectName, folderPath);
146
+ }
147
+ },
148
+
149
+ toggleExpand: (projectName: string, path: string) => {
150
+ const state = get();
151
+ const expanded = new Set(state.expandedPaths);
52
152
  if (expanded.has(path)) {
53
153
  expanded.delete(path);
154
+ set({ expandedPaths: expanded });
54
155
  } else {
55
156
  expanded.add(path);
157
+ set({ expandedPaths: expanded });
158
+ // Lazy load children if not yet loaded
159
+ if (!state.loadedPaths.has(path)) {
160
+ get().loadChildren(projectName, path);
161
+ }
56
162
  }
57
- set({ expandedPaths: expanded });
58
163
  },
59
164
 
60
165
  setExpanded: (path: string, expanded: boolean) => {
@@ -78,5 +183,25 @@ export const useFileStore = create<FileStore>((set, get) => ({
78
183
 
79
184
  clearSelection: () => set({ selectedFiles: [] }),
80
185
 
81
- reset: () => set({ tree: [], expandedPaths: new Set(), error: null, selectedFiles: [] }),
186
+ reset: () => {
187
+ // Abort all in-flight requests
188
+ for (const ctrl of get().inflight.values()) ctrl.abort();
189
+ set({
190
+ tree: [],
191
+ fileIndex: [],
192
+ loading: false,
193
+ error: null,
194
+ expandedPaths: new Set(),
195
+ loadedPaths: new Set(),
196
+ inflight: new Map(),
197
+ indexStatus: "idle",
198
+ selectedFiles: [],
199
+ });
200
+ },
201
+
202
+ /** @deprecated Alias for loadRoot — kept for callers in tab-bar and mobile-nav */
203
+ fetchTree: async (projectName: string) => {
204
+ await get().loadRoot(projectName);
205
+ get().loadIndex(projectName);
206
+ },
82
207
  }));
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Pure helper functions for immutable lazy-tree merging.
3
+ * Kept separate to stay under the 200-line file size guideline.
4
+ */
5
+ import type { FileDirEntry } from "../../types/project";
6
+ import type { FileNode } from "./file-store";
7
+
8
+ /** Convert /files/list entries into sparse FileNode children (no grandchildren). */
9
+ export function entriesToNodes(entries: FileDirEntry[], parentPath: string): FileNode[] {
10
+ return entries.map((e) => ({
11
+ name: e.name,
12
+ path: parentPath ? `${parentPath}/${e.name}` : e.name,
13
+ type: e.type,
14
+ ignored: e.isIgnored,
15
+ // children intentionally undefined — loaded lazily on expand
16
+ }));
17
+ }
18
+
19
+ /** Immutable deep-merge of newly loaded children into the sparse tree. */
20
+ export function mergeChildren(tree: FileNode[], folderPath: string, children: FileNode[]): FileNode[] {
21
+ if (!folderPath) {
22
+ // Root level: replace root entries, preserve already-loaded sub-children
23
+ return children.map((newNode) => {
24
+ const existing = tree.find((n) => n.path === newNode.path);
25
+ return existing ? { ...newNode, children: existing.children } : newNode;
26
+ });
27
+ }
28
+ return tree.map((node) => mergeNode(node, folderPath, children));
29
+ }
30
+
31
+ function mergeNode(node: FileNode, targetPath: string, children: FileNode[]): FileNode {
32
+ if (node.path === targetPath) {
33
+ // Merge: preserve already-loaded sub-children keyed by path
34
+ const mergedChildren = children.map((newChild) => {
35
+ const existing = node.children?.find((c) => c.path === newChild.path);
36
+ return existing ? { ...newChild, children: existing.children } : newChild;
37
+ });
38
+ return { ...node, children: mergedChildren };
39
+ }
40
+ if (node.children && targetPath.startsWith(node.path + "/")) {
41
+ return { ...node, children: node.children.map((child) => mergeNode(child, targetPath, children)) };
42
+ }
43
+ return node;
44
+ }
@@ -20,7 +20,7 @@ interface JiraStore {
20
20
  configs: JiraConfig[];
21
21
  selectedProjectId: number | null;
22
22
  loadConfigs: () => Promise<void>;
23
- saveConfig: (projectId: number, data: { baseUrl: string; email: string; token: string }) => Promise<void>;
23
+ saveConfig: (projectId: number, data: { baseUrl: string; email: string; token?: string }) => Promise<void>;
24
24
  deleteConfig: (projectId: number) => Promise<void>;
25
25
  testConnection: (projectId: number) => Promise<boolean>;
26
26
  setSelectedProjectId: (id: number | null) => void;
@@ -1 +0,0 @@
1
- import{r as e}from"./rolldown-runtime-FhOqtrmT.js";var t=e({api:()=>i,getAuthToken:()=>s,projectUrl:()=>a,setAuthToken:()=>o}),n=`ppm-auth-token`,r=`ppm-auth-reload-ts`,i=new class{baseUrl;constructor(e=``){this.baseUrl=e}getToken(){return localStorage.getItem(n)}headers(){let e={"Content-Type":`application/json`},t=this.getToken();return t&&(e.Authorization=`Bearer ${t}`),e}async get(e){let t=await fetch(`${this.baseUrl}${e}`,{headers:this.headers()});return this.handleResponse(t)}async post(e,t){let n=await fetch(`${this.baseUrl}${e}`,{method:`POST`,headers:this.headers(),body:t==null?void 0:JSON.stringify(t)});return this.handleResponse(n)}async put(e,t){let n=await fetch(`${this.baseUrl}${e}`,{method:`PUT`,headers:this.headers(),body:t==null?void 0:JSON.stringify(t)});return this.handleResponse(n)}async patch(e,t){let n=await fetch(`${this.baseUrl}${e}`,{method:`PATCH`,headers:this.headers(),body:t==null?void 0:JSON.stringify(t)});return this.handleResponse(n)}async del(e,t){let n=await fetch(`${this.baseUrl}${e}`,{method:`DELETE`,headers:this.headers(),body:t==null?void 0:JSON.stringify(t)});await this.handleResponse(n)}async handleResponse(e){if(e.status===401){localStorage.removeItem(n);let e=Number(sessionStorage.getItem(r)||`0`);throw Date.now()-e>3e3&&(sessionStorage.setItem(r,String(Date.now())),window.location.reload()),Error(`Unauthorized`)}let t;try{t=await e.json()}catch{throw Error(e.ok?`Empty response from server`:`Server error (HTTP ${e.status})`)}if(t.ok===!1)throw Error(t.error??`HTTP ${e.status}`);return t.data}};function a(e){return`/api/project/${encodeURIComponent(e)}`}function o(e){localStorage.setItem(n,e)}function s(){return localStorage.getItem(n)}export{o as a,a as i,t as n,s as r,i as t};