@hienlh/ppm 0.12.6 → 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.
- package/CHANGELOG.md +14 -0
- package/dist/web/assets/{ai-settings-section-BHdBBJtS.js → ai-settings-section-QE6nBNgN.js} +1 -1
- package/dist/web/assets/api-client-Dvzcc_EO.js +1 -0
- package/dist/web/assets/{api-settings-ByUGHhTB.js → api-settings-DAk7D-NP.js} +1 -1
- package/dist/web/assets/architecture-PBZL5I3N-DvZbltvY.js +1 -0
- package/dist/web/assets/{audio-preview-BMmzgbUs.js → audio-preview-DnQmf9fu.js} +1 -1
- package/dist/web/assets/chat-tab-Cf6T3mGO.js +12 -0
- package/dist/web/assets/code-editor-B-lU1fz3.js +8 -0
- package/dist/web/assets/{conflict-editor-CBietP8L.js → conflict-editor-BYzf3LuW.js} +1 -1
- package/dist/web/assets/{database-viewer-CZgooyFp.js → database-viewer-DjvnIn8p.js} +2 -2
- package/dist/web/assets/{diff-viewer-BVYjlTcF.js → diff-viewer-CP2jcR5J.js} +1 -1
- package/dist/web/assets/{extension-webview-DyZOGDb1.js → extension-webview-4xMREn_x.js} +1 -1
- package/dist/web/assets/file-store-BrbCNyLm.js +1 -0
- package/dist/web/assets/gitGraph-HDMCJU4V-BxhdxFgj.js +1 -0
- package/dist/web/assets/github-dark-dimmed.min-BrpRStFV.css +1 -0
- package/dist/web/assets/github.min-D2BCvnWf.css +1 -0
- package/dist/web/assets/{image-preview-k8_kzoHe.js → image-preview-CkS2PVdQ.js} +1 -1
- package/dist/web/assets/index-BTjuH4fn.css +2 -0
- package/dist/web/assets/index-FGlF8IWZ.js +23 -0
- package/dist/web/assets/info-3K5VOQVL-BwAZ2zd8.js +1 -0
- package/dist/web/assets/{keybindings-store-CThBg3hS.js → keybindings-store-B-zET-0o.js} +1 -1
- package/dist/web/assets/keybindings-store-DaBV6qhz.js +1 -0
- package/dist/web/assets/{markdown-renderer-CJOPseDk.js → markdown-renderer-Bj2B05Km.js} +3 -3
- package/dist/web/assets/packet-RMMSAZCW-tx2n5Qry.js +1 -0
- package/dist/web/assets/{pdf-preview-GCIIaZVw.js → pdf-preview-CCyw5cuH.js} +1 -1
- package/dist/web/assets/pie-UPGHQEXC-D6S2MqVT.js +1 -0
- package/dist/web/assets/{port-forwarding-tab-DzLa02_D.js → port-forwarding-tab-Cebb5Eix.js} +1 -1
- package/dist/web/assets/{postgres-viewer-JCT24Yqh.js → postgres-viewer-BrOiliEv.js} +2 -2
- package/dist/web/assets/radar-KQ55EAFF-BviZcL-b.js +1 -0
- package/dist/web/assets/settings-store-BLLR7ed8.js +2 -0
- package/dist/web/assets/settings-tab-D0XjupJm.js +1 -0
- package/dist/web/assets/{sql-query-editor-JwymAmuK.js → sql-query-editor-CVAnRFbi.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-nA_Biwex.js → sqlite-viewer-OEVq_-Po.js} +1 -1
- package/dist/web/assets/{terminal-tab-DvKxdDv4.js → terminal-tab-MjmJaQyA.js} +1 -1
- package/dist/web/assets/treemap-KZPCXAKY-CM54VdaB.js +1 -0
- package/dist/web/assets/{use-blob-url-BU9hYOj9.js → use-blob-url-e9uTXjv5.js} +1 -1
- package/dist/web/assets/{use-monaco-theme-o7Ip-BDL.js → use-monaco-theme-BkZDwoVd.js} +1 -1
- package/dist/web/assets/{vendor-mermaid-BlWh9BJO.js → vendor-mermaid-Dx86tuVP.js} +1 -1
- package/dist/web/assets/{video-preview-CAGgINCA.js → video-preview-B819qvlp.js} +1 -1
- package/dist/web/index.html +10 -10
- package/dist/web/sw.js +1 -1
- package/docs/journals/260421-lazy-load-file-tree-palette-index.md +125 -0
- package/docs/project-changelog.md +13 -1
- package/docs/system-architecture.md +79 -1
- package/package.json +1 -1
- package/src/server/index.ts +1 -1
- package/src/server/routes/files.ts +40 -2
- package/src/server/routes/projects.ts +53 -0
- package/src/server/routes/settings.ts +50 -1
- package/src/services/config.service.ts +41 -0
- package/src/services/db.service.ts +57 -1
- package/src/services/file-filter.service.ts +121 -0
- package/src/services/file-list-index.service.ts +170 -0
- package/src/services/file-watcher.service.ts +8 -4
- package/src/services/file.service.ts +55 -53
- package/src/services/upgrade.service.ts +2 -2
- package/src/types/chat.ts +2 -1
- package/src/types/project.ts +31 -0
- package/src/web/components/chat/file-picker.tsx +0 -13
- package/src/web/components/chat/message-input.tsx +11 -14
- package/src/web/components/chat/tool-cards.tsx +4 -2
- package/src/web/components/explorer/file-tree.tsx +91 -26
- package/src/web/components/layout/command-palette.tsx +26 -3
- package/src/web/components/settings/files-settings-section.tsx +230 -0
- package/src/web/components/settings/glob-list-editor.tsx +121 -0
- package/src/web/components/settings/settings-tab.tsx +5 -2
- package/src/web/lib/api-client.ts +2 -1
- package/src/web/lib/api-files-settings.ts +42 -0
- package/src/web/main.tsx +1 -1
- package/src/web/stores/file-store.ts +139 -14
- package/src/web/stores/file-tree-merge-helpers.ts +44 -0
- package/src/web/stores/jira-store.ts +1 -1
- package/src/web/stores/settings-store.ts +20 -0
- package/src/web/styles/globals.css +2 -8
- package/dist/web/assets/api-client-CwbMRXYl.js +0 -1
- package/dist/web/assets/architecture-PBZL5I3N-XX6_EZsC.js +0 -1
- package/dist/web/assets/chat-tab-NteLsEST.js +0 -12
- package/dist/web/assets/code-editor-Da9GXN5w.js +0 -8
- package/dist/web/assets/gitGraph-HDMCJU4V-BhjTKsbg.js +0 -1
- package/dist/web/assets/index-CDSox8V2.css +0 -2
- package/dist/web/assets/index-CXR1vYHY.js +0 -23
- package/dist/web/assets/info-3K5VOQVL-CzgVqYTx.js +0 -1
- package/dist/web/assets/keybindings-store-BIQHClUy.js +0 -1
- package/dist/web/assets/packet-RMMSAZCW-C7agXrtd.js +0 -1
- package/dist/web/assets/pie-UPGHQEXC-BRZ7alnf.js +0 -1
- package/dist/web/assets/project-store-IB6pAGQh.js +0 -1
- package/dist/web/assets/radar-KQ55EAFF-DSn_ekR5.js +0 -1
- package/dist/web/assets/settings-store-fDOEursg.js +0 -2
- package/dist/web/assets/settings-tab-bYmVV0Ww.js +0 -1
- package/dist/web/assets/treemap-KZPCXAKY-C8puYVyN.js +0 -1
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* files-settings-section.tsx
|
|
3
|
+
* Settings section for file filter configuration: filesExclude, searchExclude, useIgnoreFiles.
|
|
4
|
+
* Supports global scope and per-project override (active project only — no dropdown).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { useState, useEffect, useRef } from "react";
|
|
8
|
+
import { Switch } from "@/components/ui/switch";
|
|
9
|
+
import { Button } from "@/components/ui/button";
|
|
10
|
+
import { Label } from "@/components/ui/label";
|
|
11
|
+
import { Separator } from "@/components/ui/separator";
|
|
12
|
+
import { useProjectStore } from "@/stores/project-store";
|
|
13
|
+
import { useFileStore } from "@/stores/file-store";
|
|
14
|
+
import {
|
|
15
|
+
getFilesSettings,
|
|
16
|
+
updateFilesSettings,
|
|
17
|
+
getProjectSettings,
|
|
18
|
+
updateProjectSettings,
|
|
19
|
+
type FileFilterSettings,
|
|
20
|
+
} from "@/lib/api-files-settings";
|
|
21
|
+
import { GlobListEditor } from "./glob-list-editor";
|
|
22
|
+
|
|
23
|
+
type Scope = "global" | "project";
|
|
24
|
+
|
|
25
|
+
/** Default values used when project override has no value for a field */
|
|
26
|
+
const DEFAULTS: FileFilterSettings = {
|
|
27
|
+
filesExclude: [],
|
|
28
|
+
searchExclude: [],
|
|
29
|
+
useIgnoreFiles: true,
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export function FilesSettingsSection() {
|
|
33
|
+
const activeProject = useProjectStore((s) => s.activeProject);
|
|
34
|
+
|
|
35
|
+
const [scope, setScope] = useState<Scope>("global");
|
|
36
|
+
const [filesExclude, setFilesExclude] = useState<string[]>([]);
|
|
37
|
+
const [searchExclude, setSearchExclude] = useState<string[]>([]);
|
|
38
|
+
const [useIgnoreFiles, setUseIgnoreFiles] = useState(true);
|
|
39
|
+
const [saving, setSaving] = useState(false);
|
|
40
|
+
const [saved, setSaved] = useState(false);
|
|
41
|
+
const [error, setError] = useState<string | null>(null);
|
|
42
|
+
const [loading, setLoading] = useState(false);
|
|
43
|
+
|
|
44
|
+
// Abort controller for stale fetch cleanup
|
|
45
|
+
const abortRef = useRef<AbortController | null>(null);
|
|
46
|
+
|
|
47
|
+
// Load settings when scope or activeProject changes
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
abortRef.current?.abort();
|
|
50
|
+
const ac = new AbortController();
|
|
51
|
+
abortRef.current = ac;
|
|
52
|
+
|
|
53
|
+
setLoading(true);
|
|
54
|
+
setError(null);
|
|
55
|
+
|
|
56
|
+
const loadSettings = async () => {
|
|
57
|
+
try {
|
|
58
|
+
if (scope === "global") {
|
|
59
|
+
const s = await getFilesSettings();
|
|
60
|
+
if (ac.signal.aborted) return;
|
|
61
|
+
setFilesExclude(s.filesExclude);
|
|
62
|
+
setSearchExclude(s.searchExclude);
|
|
63
|
+
setUseIgnoreFiles(s.useIgnoreFiles);
|
|
64
|
+
} else {
|
|
65
|
+
// Per-project: fetch project override; fill missing fields with defaults
|
|
66
|
+
if (!activeProject) return;
|
|
67
|
+
const ps = await getProjectSettings(activeProject.name);
|
|
68
|
+
if (ac.signal.aborted) return;
|
|
69
|
+
const f = ps.files ?? {};
|
|
70
|
+
setFilesExclude(f.filesExclude ?? DEFAULTS.filesExclude);
|
|
71
|
+
setSearchExclude(f.searchExclude ?? DEFAULTS.searchExclude);
|
|
72
|
+
setUseIgnoreFiles(f.useIgnoreFiles ?? DEFAULTS.useIgnoreFiles);
|
|
73
|
+
}
|
|
74
|
+
} catch (e) {
|
|
75
|
+
if (ac.signal.aborted) return;
|
|
76
|
+
setError((e as Error).message);
|
|
77
|
+
} finally {
|
|
78
|
+
if (!ac.signal.aborted) setLoading(false);
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
loadSettings();
|
|
83
|
+
return () => ac.abort();
|
|
84
|
+
}, [scope, activeProject?.name]);
|
|
85
|
+
|
|
86
|
+
const handleSave = async () => {
|
|
87
|
+
setSaving(true);
|
|
88
|
+
setSaved(false);
|
|
89
|
+
setError(null);
|
|
90
|
+
try {
|
|
91
|
+
const payload: FileFilterSettings = {
|
|
92
|
+
filesExclude: filesExclude.filter((p) => p.trim() !== ""),
|
|
93
|
+
searchExclude: searchExclude.filter((p) => p.trim() !== ""),
|
|
94
|
+
useIgnoreFiles,
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
if (scope === "global") {
|
|
98
|
+
await updateFilesSettings(payload);
|
|
99
|
+
} else {
|
|
100
|
+
if (!activeProject) throw new Error("No active project");
|
|
101
|
+
await updateProjectSettings(activeProject.name, { files: payload });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Trigger server-side cache invalidation + frontend index reload
|
|
105
|
+
const store = useFileStore.getState();
|
|
106
|
+
store.invalidateIndex();
|
|
107
|
+
if (activeProject) {
|
|
108
|
+
store.loadIndex(activeProject.name);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
setSaved(true);
|
|
112
|
+
setTimeout(() => setSaved(false), 2000);
|
|
113
|
+
} catch (e) {
|
|
114
|
+
setError((e as Error).message);
|
|
115
|
+
} finally {
|
|
116
|
+
setSaving(false);
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const canSwitchToProject = !!activeProject;
|
|
121
|
+
|
|
122
|
+
return (
|
|
123
|
+
<div className="space-y-4">
|
|
124
|
+
<h3 className="text-xs font-medium text-muted-foreground">File Filters</h3>
|
|
125
|
+
|
|
126
|
+
{/* Scope toggle */}
|
|
127
|
+
<div className="flex gap-1">
|
|
128
|
+
<button
|
|
129
|
+
type="button"
|
|
130
|
+
onClick={() => setScope("global")}
|
|
131
|
+
className={`flex-1 py-1.5 rounded-md text-xs font-medium transition-colors cursor-pointer ${
|
|
132
|
+
scope === "global"
|
|
133
|
+
? "bg-primary text-primary-foreground"
|
|
134
|
+
: "bg-muted text-muted-foreground hover:bg-accent"
|
|
135
|
+
}`}
|
|
136
|
+
>
|
|
137
|
+
Global
|
|
138
|
+
</button>
|
|
139
|
+
<button
|
|
140
|
+
type="button"
|
|
141
|
+
onClick={() => canSwitchToProject && setScope("project")}
|
|
142
|
+
disabled={!canSwitchToProject}
|
|
143
|
+
title={!canSwitchToProject ? "Open a project to edit per-project overrides" : undefined}
|
|
144
|
+
className={`flex-1 py-1.5 rounded-md text-xs font-medium transition-colors cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed ${
|
|
145
|
+
scope === "project"
|
|
146
|
+
? "bg-primary text-primary-foreground"
|
|
147
|
+
: "bg-muted text-muted-foreground hover:bg-accent"
|
|
148
|
+
}`}
|
|
149
|
+
>
|
|
150
|
+
{activeProject ? activeProject.name : "Per-project"}
|
|
151
|
+
</button>
|
|
152
|
+
</div>
|
|
153
|
+
|
|
154
|
+
{scope === "project" && activeProject && (
|
|
155
|
+
<p className="text-[11px] text-muted-foreground -mt-2">
|
|
156
|
+
Overrides for <span className="font-medium">{activeProject.name}</span>.
|
|
157
|
+
Leave empty to use global settings.
|
|
158
|
+
</p>
|
|
159
|
+
)}
|
|
160
|
+
|
|
161
|
+
{loading ? (
|
|
162
|
+
<p className="text-xs text-muted-foreground">Loading...</p>
|
|
163
|
+
) : (
|
|
164
|
+
<>
|
|
165
|
+
{/* Files Exclude */}
|
|
166
|
+
<div className="space-y-1.5">
|
|
167
|
+
<Label className="text-xs">Files to Exclude</Label>
|
|
168
|
+
<p className="text-[11px] text-muted-foreground">
|
|
169
|
+
Glob patterns hidden from the file tree and palette.
|
|
170
|
+
</p>
|
|
171
|
+
<GlobListEditor
|
|
172
|
+
value={filesExclude}
|
|
173
|
+
onChange={setFilesExclude}
|
|
174
|
+
placeholder="e.g. **/*.log or node_modules/**"
|
|
175
|
+
disabled={saving}
|
|
176
|
+
/>
|
|
177
|
+
</div>
|
|
178
|
+
|
|
179
|
+
<Separator />
|
|
180
|
+
|
|
181
|
+
{/* Search Exclude */}
|
|
182
|
+
<div className="space-y-1.5">
|
|
183
|
+
<Label className="text-xs">Search to Exclude</Label>
|
|
184
|
+
<p className="text-[11px] text-muted-foreground">
|
|
185
|
+
Glob patterns excluded from file index / palette search.
|
|
186
|
+
</p>
|
|
187
|
+
<GlobListEditor
|
|
188
|
+
value={searchExclude}
|
|
189
|
+
onChange={setSearchExclude}
|
|
190
|
+
placeholder="e.g. dist/** or **/*.min.js"
|
|
191
|
+
disabled={saving}
|
|
192
|
+
/>
|
|
193
|
+
</div>
|
|
194
|
+
|
|
195
|
+
<Separator />
|
|
196
|
+
|
|
197
|
+
{/* useIgnoreFiles toggle */}
|
|
198
|
+
<div className="flex items-center justify-between gap-2">
|
|
199
|
+
<div>
|
|
200
|
+
<Label className="text-xs">Use .gitignore rules</Label>
|
|
201
|
+
<p className="text-[11px] text-muted-foreground">
|
|
202
|
+
Respect .gitignore when filtering the file tree and index.
|
|
203
|
+
</p>
|
|
204
|
+
</div>
|
|
205
|
+
<Switch
|
|
206
|
+
checked={useIgnoreFiles}
|
|
207
|
+
onCheckedChange={setUseIgnoreFiles}
|
|
208
|
+
disabled={saving}
|
|
209
|
+
/>
|
|
210
|
+
</div>
|
|
211
|
+
|
|
212
|
+
{/* Error */}
|
|
213
|
+
{error && (
|
|
214
|
+
<p className="text-[11px] text-destructive">{error}</p>
|
|
215
|
+
)}
|
|
216
|
+
|
|
217
|
+
{/* Save button */}
|
|
218
|
+
<Button
|
|
219
|
+
onClick={handleSave}
|
|
220
|
+
disabled={saving || loading}
|
|
221
|
+
size="sm"
|
|
222
|
+
className="h-8 text-xs w-full cursor-pointer"
|
|
223
|
+
>
|
|
224
|
+
{saving ? "Saving..." : saved ? "Saved" : "Save"}
|
|
225
|
+
</Button>
|
|
226
|
+
</>
|
|
227
|
+
)}
|
|
228
|
+
</div>
|
|
229
|
+
);
|
|
230
|
+
}
|
|
@@ -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
|
+
}
|
package/src/web/main.tsx
CHANGED
|
@@ -3,7 +3,7 @@ import { createRoot } from "react-dom/client";
|
|
|
3
3
|
import { App } from "./app.tsx";
|
|
4
4
|
import "./styles/globals.css";
|
|
5
5
|
import "katex/dist/katex.min.css";
|
|
6
|
-
|
|
6
|
+
// Highlight.js themes are loaded dynamically by applyThemeClass() to match light/dark mode
|
|
7
7
|
|
|
8
8
|
createRoot(document.getElementById("root")!).render(
|
|
9
9
|
<StrictMode>
|
|
@@ -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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
56
|
+
loadRoot: async (projectName: string) => {
|
|
36
57
|
set({ loading: true, error: null });
|
|
37
58
|
try {
|
|
38
|
-
const
|
|
39
|
-
`${projectUrl(projectName)}/files/
|
|
59
|
+
const data = await api.get<FileDirEntry[]>(
|
|
60
|
+
`${projectUrl(projectName)}/files/list?path=`,
|
|
40
61
|
);
|
|
41
|
-
|
|
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
|
-
|
|
51
|
-
const
|
|
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: () =>
|
|
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
|
}));
|