@hienlh/ppm 0.12.7 → 0.12.8

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 (72) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/bun.lock +2062 -0
  3. package/bunfig.toml +2 -0
  4. package/dist/web/assets/{ai-settings-section-BHdBBJtS.js → ai-settings-section-QE6nBNgN.js} +1 -1
  5. package/dist/web/assets/api-client-Dvzcc_EO.js +1 -0
  6. package/dist/web/assets/{api-settings-ByUGHhTB.js → api-settings-DAk7D-NP.js} +1 -1
  7. package/dist/web/assets/{audio-preview-A6ScJemm.js → audio-preview-DnQmf9fu.js} +1 -1
  8. package/dist/web/assets/chat-tab-Cf6T3mGO.js +12 -0
  9. package/dist/web/assets/code-editor-B-lU1fz3.js +8 -0
  10. package/dist/web/assets/{conflict-editor-DQt8Bap3.js → conflict-editor-BYzf3LuW.js} +1 -1
  11. package/dist/web/assets/{database-viewer-C1k-aq-e.js → database-viewer-DjvnIn8p.js} +2 -2
  12. package/dist/web/assets/{diff-viewer-TowzH722.js → diff-viewer-CP2jcR5J.js} +1 -1
  13. package/dist/web/assets/{extension-webview-Cn1x5C5F.js → extension-webview-4xMREn_x.js} +1 -1
  14. package/dist/web/assets/file-store-BrbCNyLm.js +1 -0
  15. package/dist/web/assets/{image-preview-MGnGKiYs.js → image-preview-CkS2PVdQ.js} +1 -1
  16. package/dist/web/assets/index-BTjuH4fn.css +2 -0
  17. package/dist/web/assets/index-FGlF8IWZ.js +23 -0
  18. package/dist/web/assets/{keybindings-store-CThBg3hS.js → keybindings-store-B-zET-0o.js} +1 -1
  19. package/dist/web/assets/keybindings-store-DaBV6qhz.js +1 -0
  20. package/dist/web/assets/{markdown-renderer-DSINJjCx.js → markdown-renderer-Bj2B05Km.js} +1 -1
  21. package/dist/web/assets/{pdf-preview-BiI5Qihn.js → pdf-preview-CCyw5cuH.js} +1 -1
  22. package/dist/web/assets/{port-forwarding-tab-jjdgxhoi.js → port-forwarding-tab-Cebb5Eix.js} +1 -1
  23. package/dist/web/assets/{postgres-viewer-BwXJ-fGk.js → postgres-viewer-BrOiliEv.js} +2 -2
  24. package/dist/web/assets/{settings-store-BMZgnYTp.js → settings-store-BLLR7ed8.js} +2 -2
  25. package/dist/web/assets/settings-tab-D0XjupJm.js +1 -0
  26. package/dist/web/assets/{sql-query-editor-BSHd21AE.js → sql-query-editor-CVAnRFbi.js} +1 -1
  27. package/dist/web/assets/{sqlite-viewer-BPywcOES.js → sqlite-viewer-OEVq_-Po.js} +1 -1
  28. package/dist/web/assets/{terminal-tab-Civ2Yhce.js → terminal-tab-MjmJaQyA.js} +1 -1
  29. package/dist/web/assets/{use-blob-url-BU9hYOj9.js → use-blob-url-e9uTXjv5.js} +1 -1
  30. package/dist/web/assets/{use-monaco-theme-CXs7t0_G.js → use-monaco-theme-BkZDwoVd.js} +1 -1
  31. package/dist/web/assets/{video-preview-Db5TkPSt.js → video-preview-B819qvlp.js} +1 -1
  32. package/dist/web/index.html +8 -8
  33. package/dist/web/sw.js +1 -1
  34. package/docs/journals/260421-lazy-load-file-tree-palette-index.md +125 -0
  35. package/docs/project-changelog.md +13 -1
  36. package/docs/system-architecture.md +79 -1
  37. package/package.json +1 -1
  38. package/src/index.ts +0 -0
  39. package/src/server/index.ts +1 -1
  40. package/src/server/routes/files.ts +40 -2
  41. package/src/server/routes/projects.ts +53 -0
  42. package/src/server/routes/settings.ts +50 -1
  43. package/src/services/config.service.ts +41 -0
  44. package/src/services/db.service.ts +57 -1
  45. package/src/services/file-filter.service.ts +121 -0
  46. package/src/services/file-list-index.service.ts +170 -0
  47. package/src/services/file-watcher.service.ts +8 -4
  48. package/src/services/file.service.ts +55 -53
  49. package/src/services/upgrade.service.ts +2 -2
  50. package/src/types/chat.ts +2 -1
  51. package/src/types/project.ts +31 -0
  52. package/src/web/components/chat/file-picker.tsx +0 -13
  53. package/src/web/components/chat/message-input.tsx +11 -14
  54. package/src/web/components/chat/tool-cards.tsx +4 -2
  55. package/src/web/components/explorer/file-tree.tsx +91 -26
  56. package/src/web/components/layout/command-palette.tsx +26 -3
  57. package/src/web/components/settings/files-settings-section.tsx +230 -0
  58. package/src/web/components/settings/glob-list-editor.tsx +121 -0
  59. package/src/web/components/settings/settings-tab.tsx +5 -2
  60. package/src/web/lib/api-client.ts +2 -1
  61. package/src/web/lib/api-files-settings.ts +42 -0
  62. package/src/web/stores/file-store.ts +139 -14
  63. package/src/web/stores/file-tree-merge-helpers.ts +44 -0
  64. package/src/web/stores/jira-store.ts +1 -1
  65. package/dist/web/assets/api-client-CwbMRXYl.js +0 -1
  66. package/dist/web/assets/chat-tab--Rc7WIJp.js +0 -12
  67. package/dist/web/assets/code-editor-DZSUYMBx.js +0 -8
  68. package/dist/web/assets/index-BrAupjGV.css +0 -2
  69. package/dist/web/assets/index-gxtJiPiW.js +0 -23
  70. package/dist/web/assets/keybindings-store-BIQHClUy.js +0 -1
  71. package/dist/web/assets/project-store-IB6pAGQh.js +0 -1
  72. package/dist/web/assets/settings-tab-USIB-LOd.js +0 -1
@@ -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};