@hienlh/ppm 0.7.3 → 0.7.5
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 +13 -0
- package/dist/web/assets/{chat-tab--EEbRVaN.js → chat-tab-DXtCcWg2.js} +1 -1
- package/dist/web/assets/code-editor-CxMuSJrX.js +1 -0
- package/dist/web/assets/{database-viewer-CUo4184l.js → database-viewer-C6riNG1v.js} +1 -1
- package/dist/web/assets/{diff-viewer-C9pppOGA.js → diff-viewer-CQIockZr.js} +1 -1
- package/dist/web/assets/{git-graph-5k5QQSst.js → git-graph-D2BR2rfP.js} +1 -1
- package/dist/web/assets/index-4pPCbWJp.css +2 -0
- package/dist/web/assets/index-B9ZHWVob.js +29 -0
- package/dist/web/assets/keybindings-store-fcYIcK0C.js +1 -0
- package/dist/web/assets/{markdown-renderer-CxhgCtxp.js → markdown-renderer-DKVNZXEw.js} +1 -1
- package/dist/web/assets/{postgres-viewer-V8p5zyzM.js → postgres-viewer-DHvwHGEL.js} +1 -1
- package/dist/web/assets/settings-store-DS-ifJ7c.js +1 -0
- package/dist/web/assets/settings-tab-D_quOlcC.js +1 -0
- package/dist/web/assets/{sqlite-viewer-CrmQ2d_g.js → sqlite-viewer-C5Vj_kSU.js} +1 -1
- package/dist/web/assets/{terminal-tab-DP4NxGnK.js → terminal-tab-B95lVSty.js} +1 -1
- package/dist/web/assets/{use-monaco-theme-CeYne1fd.js → use-monaco-theme-M04jkKDM.js} +1 -1
- package/dist/web/index.html +3 -3
- package/dist/web/sw.js +1 -1
- package/package.json +1 -1
- package/src/server/routes/files.ts +81 -0
- package/src/services/claude-usage.service.ts +75 -35
- package/src/services/db.service.ts +65 -1
- package/src/web/components/editor/code-editor.tsx +10 -0
- package/src/web/components/explorer/search-panel.tsx +310 -0
- package/src/web/components/layout/sidebar.tsx +6 -1
- package/src/web/hooks/use-global-keybindings.ts +9 -0
- package/src/web/stores/keybindings-store.ts +1 -0
- package/src/web/stores/settings-store.ts +2 -2
- package/dist/web/assets/code-editor-DiJdsNLG.js +0 -1
- package/dist/web/assets/index-Bl899cTX.css +0 -2
- package/dist/web/assets/index-DJ9J8ofO.js +0 -29
- package/dist/web/assets/keybindings-store-BKy-JlvM.js +0 -1
- package/dist/web/assets/settings-store-oxI7uvce.js +0 -1
- package/dist/web/assets/settings-tab-BHdd7GQK.js +0 -1
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { homedir } from "node:os";
|
|
2
2
|
import { resolve } from "node:path";
|
|
3
3
|
import { existsSync, readFileSync } from "node:fs";
|
|
4
|
+
import {
|
|
5
|
+
insertLimitSnapshot,
|
|
6
|
+
getLatestLimitSnapshot,
|
|
7
|
+
cleanupOldLimitSnapshots,
|
|
8
|
+
type LimitSnapshotRow,
|
|
9
|
+
} from "./db.service.ts";
|
|
4
10
|
|
|
5
11
|
export interface LimitBucket {
|
|
6
12
|
utilization: number;
|
|
@@ -25,12 +31,12 @@ const API_URL = "https://api.anthropic.com/api/oauth/usage";
|
|
|
25
31
|
const API_BETA = "oauth-2025-04-20";
|
|
26
32
|
const USER_AGENT = "claude-code/1.0";
|
|
27
33
|
const FETCH_TIMEOUT = 10_000; // 10s
|
|
28
|
-
const POLL_INTERVAL =
|
|
34
|
+
const POLL_INTERVAL = 120_000; // auto-fetch every 2min
|
|
29
35
|
const RETRY_DELAY = 5_000; // 5s between retries
|
|
30
36
|
const MAX_RETRIES = 3;
|
|
31
37
|
|
|
32
|
-
/**
|
|
33
|
-
let
|
|
38
|
+
/** In-memory accumulator for cost from SDK result events */
|
|
39
|
+
let inMemoryCostUsd = 0;
|
|
34
40
|
|
|
35
41
|
/** Cached OAuth token (read once from Keychain/file) */
|
|
36
42
|
let tokenCache: { token: string; timestamp: number } | null = null;
|
|
@@ -118,12 +124,69 @@ function parseApiBucket(raw: Record<string, any>, windowHours: number): LimitBuc
|
|
|
118
124
|
};
|
|
119
125
|
}
|
|
120
126
|
|
|
121
|
-
/**
|
|
127
|
+
/** Convert DB snapshot row fields back to a LimitBucket (recomputes time-relative fields) */
|
|
128
|
+
function dbBucketToLimitBucket(util: number, resetsAt: string, windowHours: number): LimitBucket {
|
|
129
|
+
const diff = resetsAt ? new Date(resetsAt).getTime() - Date.now() : 0;
|
|
130
|
+
const totalMins = diff > 0 ? Math.ceil(diff / 60_000) : 0;
|
|
131
|
+
return {
|
|
132
|
+
utilization: util,
|
|
133
|
+
resetsAt,
|
|
134
|
+
resetsInMinutes: windowHours <= 5 ? totalMins : null,
|
|
135
|
+
resetsInHours: windowHours > 5 ? Math.round((totalMins / 60) * 100) / 100 : null,
|
|
136
|
+
windowHours,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Return ClaudeUsage from the latest DB snapshot + in-memory cost */
|
|
141
|
+
export function getCachedUsage(): ClaudeUsage {
|
|
142
|
+
const row = getLatestLimitSnapshot();
|
|
143
|
+
const result: ClaudeUsage = {};
|
|
144
|
+
if (inMemoryCostUsd > 0) result.totalCostUsd = inMemoryCostUsd;
|
|
145
|
+
if (!row) return result;
|
|
146
|
+
result.lastFetchedAt = row.recorded_at;
|
|
147
|
+
if (row.five_hour_util != null) result.session = dbBucketToLimitBucket(row.five_hour_util, row.five_hour_resets_at ?? "", 5);
|
|
148
|
+
if (row.weekly_util != null) result.weekly = dbBucketToLimitBucket(row.weekly_util, row.weekly_resets_at ?? "", 168);
|
|
149
|
+
if (row.weekly_opus_util != null) result.weeklyOpus = dbBucketToLimitBucket(row.weekly_opus_util, row.weekly_opus_resets_at ?? "", 168);
|
|
150
|
+
if (row.weekly_sonnet_util != null) result.weeklySonnet = dbBucketToLimitBucket(row.weekly_sonnet_util, row.weekly_sonnet_resets_at ?? "", 168);
|
|
151
|
+
return result;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Check if new API data differs from the last DB snapshot enough to warrant a new row */
|
|
155
|
+
function hasChanged(data: ClaudeUsage, last: LimitSnapshotRow | null): boolean {
|
|
156
|
+
if (!last) return true;
|
|
157
|
+
const diff = (a: number | null | undefined, b: number | null) =>
|
|
158
|
+
a != null && (b == null || Math.abs(a - b) > 0.001);
|
|
159
|
+
if (diff(data.session?.utilization, last.five_hour_util)) return true;
|
|
160
|
+
if (diff(data.weekly?.utilization, last.weekly_util)) return true;
|
|
161
|
+
// Detect window reset (resetsAt changed)
|
|
162
|
+
if (data.session?.resetsAt && data.session.resetsAt !== (last.five_hour_resets_at ?? "")) return true;
|
|
163
|
+
if (data.weekly?.resetsAt && data.weekly.resetsAt !== (last.weekly_resets_at ?? "")) return true;
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Persist API data to DB if changed, then cleanup old rows */
|
|
168
|
+
function persistIfChanged(data: ClaudeUsage): void {
|
|
169
|
+
const last = getLatestLimitSnapshot();
|
|
170
|
+
if (!hasChanged(data, last)) return;
|
|
171
|
+
insertLimitSnapshot({
|
|
172
|
+
five_hour_util: data.session?.utilization ?? null,
|
|
173
|
+
five_hour_resets_at: data.session?.resetsAt ?? null,
|
|
174
|
+
weekly_util: data.weekly?.utilization ?? null,
|
|
175
|
+
weekly_resets_at: data.weekly?.resetsAt ?? null,
|
|
176
|
+
weekly_opus_util: data.weeklyOpus?.utilization ?? null,
|
|
177
|
+
weekly_opus_resets_at: data.weeklyOpus?.resetsAt ?? null,
|
|
178
|
+
weekly_sonnet_util: data.weeklySonnet?.utilization ?? null,
|
|
179
|
+
weekly_sonnet_resets_at: data.weeklySonnet?.resetsAt ?? null,
|
|
180
|
+
});
|
|
181
|
+
cleanupOldLimitSnapshots();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Fetch with retry logic, persist to DB if changed */
|
|
122
185
|
async function fetchWithRetry(): Promise<void> {
|
|
123
186
|
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
124
187
|
try {
|
|
125
188
|
const data = await fetchUsageFromApi();
|
|
126
|
-
|
|
189
|
+
persistIfChanged(data);
|
|
127
190
|
return;
|
|
128
191
|
} catch (e) {
|
|
129
192
|
const msg = (e as Error).message ?? "";
|
|
@@ -150,45 +213,22 @@ export function stopUsagePolling(): void {
|
|
|
150
213
|
if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
|
|
151
214
|
}
|
|
152
215
|
|
|
153
|
-
/** Get cached usage (fast, synchronous read — FE just reads this) */
|
|
154
|
-
export function getCachedUsage(): ClaudeUsage {
|
|
155
|
-
return cache;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
216
|
/**
|
|
159
|
-
* Merge SDK
|
|
160
|
-
*
|
|
217
|
+
* Merge SDK result cost events into in-memory accumulator.
|
|
218
|
+
* Rate limit utilization from SDK events is ignored — API polling is authoritative.
|
|
161
219
|
*/
|
|
162
220
|
export function updateFromSdkEvent(
|
|
163
|
-
|
|
164
|
-
|
|
221
|
+
_rateLimitType?: string,
|
|
222
|
+
_utilization?: number,
|
|
165
223
|
costUsd?: number,
|
|
166
224
|
): void {
|
|
167
|
-
if (rateLimitType && utilization != null) {
|
|
168
|
-
if (rateLimitType === "five_hour") {
|
|
169
|
-
cache.session = {
|
|
170
|
-
...(cache.session ?? { resetsAt: "", resetsInMinutes: null, resetsInHours: null, windowHours: 5 }),
|
|
171
|
-
utilization,
|
|
172
|
-
};
|
|
173
|
-
} else if (rateLimitType.startsWith("seven_day")) {
|
|
174
|
-
const key: keyof ClaudeUsage =
|
|
175
|
-
rateLimitType === "seven_day_opus" ? "weeklyOpus"
|
|
176
|
-
: rateLimitType === "seven_day_sonnet" ? "weeklySonnet"
|
|
177
|
-
: "weekly";
|
|
178
|
-
cache[key] = {
|
|
179
|
-
...(cache[key] as LimitBucket ?? { resetsAt: "", resetsInMinutes: null, resetsInHours: null, windowHours: 168 }),
|
|
180
|
-
utilization,
|
|
181
|
-
};
|
|
182
|
-
}
|
|
183
|
-
if (!cache.lastFetchedAt) cache.lastFetchedAt = new Date().toISOString();
|
|
184
|
-
}
|
|
185
225
|
if (costUsd != null) {
|
|
186
|
-
|
|
226
|
+
inMemoryCostUsd += costUsd;
|
|
187
227
|
}
|
|
188
228
|
}
|
|
189
229
|
|
|
190
|
-
/** Force immediate refresh
|
|
230
|
+
/** Force immediate refresh from Anthropic API, persist to DB, return latest */
|
|
191
231
|
export async function refreshUsageNow(): Promise<ClaudeUsage> {
|
|
192
232
|
await fetchWithRetry();
|
|
193
|
-
return
|
|
233
|
+
return getCachedUsage();
|
|
194
234
|
}
|
|
@@ -4,7 +4,7 @@ import { homedir } from "node:os";
|
|
|
4
4
|
import { mkdirSync, existsSync } from "node:fs";
|
|
5
5
|
|
|
6
6
|
const PPM_DIR = resolve(homedir(), ".ppm");
|
|
7
|
-
const CURRENT_SCHEMA_VERSION =
|
|
7
|
+
const CURRENT_SCHEMA_VERSION = 4;
|
|
8
8
|
|
|
9
9
|
let db: Database | null = null;
|
|
10
10
|
let dbProfile: string | null = null;
|
|
@@ -167,6 +167,27 @@ function runMigrations(database: Database): void {
|
|
|
167
167
|
PRAGMA user_version = 3;
|
|
168
168
|
`);
|
|
169
169
|
}
|
|
170
|
+
|
|
171
|
+
if (current < 4) {
|
|
172
|
+
database.exec(`
|
|
173
|
+
CREATE TABLE IF NOT EXISTS claude_limit_snapshots (
|
|
174
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
175
|
+
five_hour_util REAL,
|
|
176
|
+
five_hour_resets_at TEXT,
|
|
177
|
+
weekly_util REAL,
|
|
178
|
+
weekly_resets_at TEXT,
|
|
179
|
+
weekly_opus_util REAL,
|
|
180
|
+
weekly_opus_resets_at TEXT,
|
|
181
|
+
weekly_sonnet_util REAL,
|
|
182
|
+
weekly_sonnet_resets_at TEXT,
|
|
183
|
+
recorded_at TEXT DEFAULT (datetime('now'))
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
CREATE INDEX IF NOT EXISTS idx_limit_snapshots_recorded ON claude_limit_snapshots(recorded_at);
|
|
187
|
+
|
|
188
|
+
PRAGMA user_version = 4;
|
|
189
|
+
`);
|
|
190
|
+
}
|
|
170
191
|
}
|
|
171
192
|
|
|
172
193
|
// ---------------------------------------------------------------------------
|
|
@@ -345,6 +366,49 @@ export function getDbFilePath(): string {
|
|
|
345
366
|
return getDbPath();
|
|
346
367
|
}
|
|
347
368
|
|
|
369
|
+
// ---------------------------------------------------------------------------
|
|
370
|
+
// Claude limit snapshot helpers
|
|
371
|
+
// ---------------------------------------------------------------------------
|
|
372
|
+
|
|
373
|
+
export interface LimitSnapshotRow {
|
|
374
|
+
id: number;
|
|
375
|
+
five_hour_util: number | null;
|
|
376
|
+
five_hour_resets_at: string | null;
|
|
377
|
+
weekly_util: number | null;
|
|
378
|
+
weekly_resets_at: string | null;
|
|
379
|
+
weekly_opus_util: number | null;
|
|
380
|
+
weekly_opus_resets_at: string | null;
|
|
381
|
+
weekly_sonnet_util: number | null;
|
|
382
|
+
weekly_sonnet_resets_at: string | null;
|
|
383
|
+
recorded_at: string;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
export function insertLimitSnapshot(data: Omit<LimitSnapshotRow, "id" | "recorded_at">): void {
|
|
387
|
+
getDb().query(
|
|
388
|
+
`INSERT INTO claude_limit_snapshots
|
|
389
|
+
(five_hour_util, five_hour_resets_at, weekly_util, weekly_resets_at,
|
|
390
|
+
weekly_opus_util, weekly_opus_resets_at, weekly_sonnet_util, weekly_sonnet_resets_at)
|
|
391
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
392
|
+
).run(
|
|
393
|
+
data.five_hour_util ?? null, data.five_hour_resets_at ?? null,
|
|
394
|
+
data.weekly_util ?? null, data.weekly_resets_at ?? null,
|
|
395
|
+
data.weekly_opus_util ?? null, data.weekly_opus_resets_at ?? null,
|
|
396
|
+
data.weekly_sonnet_util ?? null, data.weekly_sonnet_resets_at ?? null,
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
export function getLatestLimitSnapshot(): LimitSnapshotRow | null {
|
|
401
|
+
return getDb().query(
|
|
402
|
+
"SELECT * FROM claude_limit_snapshots ORDER BY recorded_at DESC LIMIT 1",
|
|
403
|
+
).get() as LimitSnapshotRow | null;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
export function cleanupOldLimitSnapshots(): void {
|
|
407
|
+
getDb().query(
|
|
408
|
+
"DELETE FROM claude_limit_snapshots WHERE recorded_at < datetime('now', '-7 days')",
|
|
409
|
+
).run();
|
|
410
|
+
}
|
|
411
|
+
|
|
348
412
|
// ---------------------------------------------------------------------------
|
|
349
413
|
// Connection helpers
|
|
350
414
|
// ---------------------------------------------------------------------------
|
|
@@ -130,8 +130,18 @@ export function CodeEditor({ metadata, tabId }: CodeEditorProps) {
|
|
|
130
130
|
saveTimerRef.current = setTimeout(() => saveFile(latestContentRef.current), 1000);
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
+
// Jump to line when metadata.lineNumber is set (e.g. from search panel)
|
|
134
|
+
const lineNumber = metadata?.lineNumber as number | undefined;
|
|
133
135
|
const handleEditorMount: OnMount = useCallback((editor, monaco) => {
|
|
134
136
|
editorRef.current = editor;
|
|
137
|
+
if (lineNumber && lineNumber > 0) {
|
|
138
|
+
// Defer until content is rendered
|
|
139
|
+
setTimeout(() => {
|
|
140
|
+
editor.revealLineInCenter(lineNumber);
|
|
141
|
+
editor.setPosition({ lineNumber, column: 1 });
|
|
142
|
+
editor.focus();
|
|
143
|
+
}, 100);
|
|
144
|
+
}
|
|
135
145
|
// Alt+Z → toggle word wrap
|
|
136
146
|
editor.addCommand(
|
|
137
147
|
monaco.KeyMod.Alt | monaco.KeyCode.KeyZ,
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
import { useState, useRef, useCallback, useEffect } from "react";
|
|
2
|
+
import { Search, CaseSensitive, ChevronRight, ChevronDown, FileText, X, Loader2, WholeWord, Regex, ReplaceAll } from "lucide-react";
|
|
3
|
+
import { useProjectStore } from "@/stores/project-store";
|
|
4
|
+
import { useTabStore } from "@/stores/tab-store";
|
|
5
|
+
import { projectUrl, api } from "@/lib/api-client";
|
|
6
|
+
import { cn } from "@/lib/utils";
|
|
7
|
+
|
|
8
|
+
interface SearchMatch {
|
|
9
|
+
lineNum: number;
|
|
10
|
+
content: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface SearchResult {
|
|
14
|
+
file: string;
|
|
15
|
+
matches: SearchMatch[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Build highlight regex from query + options */
|
|
19
|
+
function buildHighlightRegex(query: string, caseSensitive: boolean, wholeWord: boolean, useRegex: boolean): RegExp | null {
|
|
20
|
+
if (!query) return null;
|
|
21
|
+
try {
|
|
22
|
+
let pattern = useRegex ? query : query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
23
|
+
if (wholeWord) pattern = `\\b${pattern}\\b`;
|
|
24
|
+
return new RegExp(`(${pattern})`, caseSensitive ? "g" : "gi");
|
|
25
|
+
} catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function HighlightMatch({ text, re }: { text: string; re: RegExp | null }) {
|
|
31
|
+
if (!re) return <span>{text}</span>;
|
|
32
|
+
try {
|
|
33
|
+
re.lastIndex = 0;
|
|
34
|
+
const parts = text.split(re);
|
|
35
|
+
re.lastIndex = 0;
|
|
36
|
+
return (
|
|
37
|
+
<span>
|
|
38
|
+
{parts.map((p, i) => {
|
|
39
|
+
re.lastIndex = 0;
|
|
40
|
+
return re.test(p) ? <mark key={i} className="bg-yellow-300/40 text-foreground rounded-sm">{p}</mark> : p;
|
|
41
|
+
})}
|
|
42
|
+
</span>
|
|
43
|
+
);
|
|
44
|
+
} catch {
|
|
45
|
+
return <span>{text}</span>;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function OptionButton({ active, onClick, title, children }: { active: boolean; onClick: () => void; title: string; children: React.ReactNode }) {
|
|
50
|
+
return (
|
|
51
|
+
<button
|
|
52
|
+
onClick={onClick}
|
|
53
|
+
title={title}
|
|
54
|
+
className={cn(
|
|
55
|
+
"flex items-center justify-center w-6 h-6 rounded border shrink-0",
|
|
56
|
+
active ? "border-primary text-primary bg-primary/10" : "border-border text-text-subtle hover:text-foreground hover:border-border/80"
|
|
57
|
+
)}
|
|
58
|
+
>
|
|
59
|
+
{children}
|
|
60
|
+
</button>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function SearchPanel() {
|
|
65
|
+
const { activeProject } = useProjectStore();
|
|
66
|
+
const openTab = useTabStore((s) => s.openTab);
|
|
67
|
+
|
|
68
|
+
const [query, setQuery] = useState("");
|
|
69
|
+
const [caseSensitive, setCaseSensitive] = useState(false);
|
|
70
|
+
const [wholeWord, setWholeWord] = useState(false);
|
|
71
|
+
const [useRegex, setUseRegex] = useState(false);
|
|
72
|
+
const [regexError, setRegexError] = useState(false);
|
|
73
|
+
const [filesFilter, setFilesFilter] = useState("");
|
|
74
|
+
const [replace, setReplace] = useState("");
|
|
75
|
+
const [replacing, setReplacing] = useState(false);
|
|
76
|
+
const [replaceCount, setReplaceCount] = useState<number | null>(null);
|
|
77
|
+
const [results, setResults] = useState<SearchResult[]>([]);
|
|
78
|
+
const [total, setTotal] = useState(0);
|
|
79
|
+
const [loading, setLoading] = useState(false);
|
|
80
|
+
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
|
|
81
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
82
|
+
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
83
|
+
|
|
84
|
+
const doSearch = useCallback(async (q: string, cs: boolean, ww: boolean, rx: boolean, ff: string) => {
|
|
85
|
+
setRegexError(false);
|
|
86
|
+
if (!activeProject || (!rx && q.length < 2) || (rx && q.length < 1)) {
|
|
87
|
+
setResults([]);
|
|
88
|
+
setTotal(0);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (rx) {
|
|
92
|
+
try { new RegExp(q); } catch { setRegexError(true); setResults([]); return; }
|
|
93
|
+
}
|
|
94
|
+
setLoading(true);
|
|
95
|
+
try {
|
|
96
|
+
const params = new URLSearchParams({ q, caseSensitive: String(cs), wholeWord: String(ww), regex: String(rx) });
|
|
97
|
+
if (ff) params.set("include", ff);
|
|
98
|
+
const data = await api.get<{ results: SearchResult[]; total: number }>(
|
|
99
|
+
`${projectUrl(activeProject.name)}/files/search?${params}`
|
|
100
|
+
);
|
|
101
|
+
setResults(data.results);
|
|
102
|
+
setTotal(data.total);
|
|
103
|
+
} catch {
|
|
104
|
+
setResults([]);
|
|
105
|
+
} finally {
|
|
106
|
+
setLoading(false);
|
|
107
|
+
}
|
|
108
|
+
}, [activeProject]);
|
|
109
|
+
|
|
110
|
+
useEffect(() => {
|
|
111
|
+
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
112
|
+
debounceRef.current = setTimeout(() => doSearch(query, caseSensitive, wholeWord, useRegex, filesFilter), 300);
|
|
113
|
+
return () => { if (debounceRef.current) clearTimeout(debounceRef.current); };
|
|
114
|
+
}, [query, caseSensitive, wholeWord, useRegex, filesFilter, doSearch]);
|
|
115
|
+
|
|
116
|
+
useEffect(() => { inputRef.current?.focus(); }, []);
|
|
117
|
+
|
|
118
|
+
const highlightRe = buildHighlightRegex(query, caseSensitive, wholeWord, useRegex);
|
|
119
|
+
|
|
120
|
+
function openFile(file: string, lineNum?: number) {
|
|
121
|
+
if (!activeProject) return;
|
|
122
|
+
const name = file.split("/").pop() ?? file;
|
|
123
|
+
openTab({
|
|
124
|
+
type: "editor",
|
|
125
|
+
title: name,
|
|
126
|
+
metadata: { filePath: file, projectName: activeProject.name, lineNumber: lineNum },
|
|
127
|
+
projectId: activeProject.name,
|
|
128
|
+
closable: true,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function toggleCollapse(file: string) {
|
|
133
|
+
setCollapsed((prev) => {
|
|
134
|
+
const next = new Set(prev);
|
|
135
|
+
next.has(file) ? next.delete(file) : next.add(file);
|
|
136
|
+
return next;
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function doReplaceAll() {
|
|
141
|
+
if (!activeProject || !query || results.length === 0 || replacing) return;
|
|
142
|
+
setReplacing(true);
|
|
143
|
+
setReplaceCount(null);
|
|
144
|
+
let count = 0;
|
|
145
|
+
try {
|
|
146
|
+
for (const r of results) {
|
|
147
|
+
const fileData = await api.get<{ content: string }>(
|
|
148
|
+
`${projectUrl(activeProject.name)}/files/read?path=${encodeURIComponent(r.file)}`
|
|
149
|
+
);
|
|
150
|
+
const re = buildHighlightRegex(query, caseSensitive, wholeWord, useRegex);
|
|
151
|
+
if (!re) continue;
|
|
152
|
+
re.lastIndex = 0;
|
|
153
|
+
const matches = fileData.content.match(re) ?? [];
|
|
154
|
+
if (!matches.length) continue;
|
|
155
|
+
count += matches.length;
|
|
156
|
+
re.lastIndex = 0;
|
|
157
|
+
const newContent = fileData.content.replace(re, replace);
|
|
158
|
+
await api.put(`${projectUrl(activeProject.name)}/files/write`, { path: r.file, content: newContent });
|
|
159
|
+
}
|
|
160
|
+
setReplaceCount(count);
|
|
161
|
+
doSearch(query, caseSensitive, wholeWord, useRegex, filesFilter);
|
|
162
|
+
} catch {
|
|
163
|
+
// ignore
|
|
164
|
+
} finally {
|
|
165
|
+
setReplacing(false);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return (
|
|
170
|
+
<div className="flex flex-col h-full">
|
|
171
|
+
{/* Search input + options */}
|
|
172
|
+
<div className="p-2 border-b border-border space-y-1.5">
|
|
173
|
+
{/* Search row */}
|
|
174
|
+
<div className="relative flex items-center gap-1">
|
|
175
|
+
<div className="relative flex-1">
|
|
176
|
+
<Search className="absolute left-2 top-1/2 -translate-y-1/2 size-3.5 text-text-subtle pointer-events-none" />
|
|
177
|
+
<input
|
|
178
|
+
ref={inputRef}
|
|
179
|
+
value={query}
|
|
180
|
+
onChange={(e) => { setQuery(e.target.value); setReplaceCount(null); }}
|
|
181
|
+
placeholder={activeProject ? "Search files…" : "Select a project first"}
|
|
182
|
+
disabled={!activeProject}
|
|
183
|
+
className={cn(
|
|
184
|
+
"w-full pl-7 pr-6 py-1 text-xs bg-input border rounded focus:outline-none focus:ring-1 focus:ring-primary/50 disabled:opacity-50",
|
|
185
|
+
regexError ? "border-destructive" : "border-border"
|
|
186
|
+
)}
|
|
187
|
+
/>
|
|
188
|
+
{query && (
|
|
189
|
+
<button
|
|
190
|
+
onClick={() => { setQuery(""); setResults([]); setTotal(0); setRegexError(false); setReplaceCount(null); }}
|
|
191
|
+
className="absolute right-1.5 top-1/2 -translate-y-1/2 text-text-subtle hover:text-foreground"
|
|
192
|
+
>
|
|
193
|
+
<X className="size-3" />
|
|
194
|
+
</button>
|
|
195
|
+
)}
|
|
196
|
+
</div>
|
|
197
|
+
</div>
|
|
198
|
+
|
|
199
|
+
{/* Option toggles */}
|
|
200
|
+
<div className="flex items-center gap-1">
|
|
201
|
+
<OptionButton active={caseSensitive} onClick={() => setCaseSensitive((v) => !v)} title="Match Case (Alt+C)">
|
|
202
|
+
<CaseSensitive className="size-3.5" />
|
|
203
|
+
</OptionButton>
|
|
204
|
+
<OptionButton active={wholeWord} onClick={() => { setWholeWord((v) => !v); if (useRegex) setUseRegex(false); }} title="Match Whole Word (Alt+W)">
|
|
205
|
+
<WholeWord className="size-3.5" />
|
|
206
|
+
</OptionButton>
|
|
207
|
+
<OptionButton active={useRegex} onClick={() => { setUseRegex((v) => !v); if (wholeWord) setWholeWord(false); }} title="Use Regular Expression (Alt+R)">
|
|
208
|
+
<Regex className="size-3.5" />
|
|
209
|
+
</OptionButton>
|
|
210
|
+
{regexError && <span className="text-[10px] text-destructive ml-1">Invalid regex</span>}
|
|
211
|
+
</div>
|
|
212
|
+
|
|
213
|
+
{/* Replace row */}
|
|
214
|
+
<div className="flex items-center gap-1">
|
|
215
|
+
<div className="relative flex-1">
|
|
216
|
+
<input
|
|
217
|
+
value={replace}
|
|
218
|
+
onChange={(e) => setReplace(e.target.value)}
|
|
219
|
+
placeholder="Replace…"
|
|
220
|
+
disabled={!activeProject}
|
|
221
|
+
className="w-full pl-2 pr-6 py-1 text-xs bg-input border border-border rounded focus:outline-none focus:ring-1 focus:ring-primary/50 disabled:opacity-50"
|
|
222
|
+
/>
|
|
223
|
+
{replace && (
|
|
224
|
+
<button onClick={() => setReplace("")} className="absolute right-1.5 top-1/2 -translate-y-1/2 text-text-subtle hover:text-foreground">
|
|
225
|
+
<X className="size-3" />
|
|
226
|
+
</button>
|
|
227
|
+
)}
|
|
228
|
+
</div>
|
|
229
|
+
<button
|
|
230
|
+
onClick={doReplaceAll}
|
|
231
|
+
disabled={!query || results.length === 0 || replacing}
|
|
232
|
+
title="Replace All"
|
|
233
|
+
className="flex items-center justify-center w-6 h-6 rounded border border-border text-text-subtle hover:text-foreground hover:border-border/80 disabled:opacity-40 shrink-0"
|
|
234
|
+
>
|
|
235
|
+
{replacing ? <Loader2 className="size-3.5 animate-spin" /> : <ReplaceAll className="size-3.5" />}
|
|
236
|
+
</button>
|
|
237
|
+
</div>
|
|
238
|
+
|
|
239
|
+
{/* Files filter row */}
|
|
240
|
+
<div className="relative">
|
|
241
|
+
<input
|
|
242
|
+
value={filesFilter}
|
|
243
|
+
onChange={(e) => setFilesFilter(e.target.value)}
|
|
244
|
+
placeholder="Files to include (e.g. *.ts, src/**)"
|
|
245
|
+
disabled={!activeProject}
|
|
246
|
+
className="w-full pl-2 pr-6 py-1 text-xs bg-input border border-border rounded focus:outline-none focus:ring-1 focus:ring-primary/50 disabled:opacity-50"
|
|
247
|
+
/>
|
|
248
|
+
{filesFilter && (
|
|
249
|
+
<button onClick={() => setFilesFilter("")} className="absolute right-1.5 top-1/2 -translate-y-1/2 text-text-subtle hover:text-foreground">
|
|
250
|
+
<X className="size-3" />
|
|
251
|
+
</button>
|
|
252
|
+
)}
|
|
253
|
+
</div>
|
|
254
|
+
|
|
255
|
+
{/* Status line */}
|
|
256
|
+
<div className="text-[10px] text-text-subtle h-3">
|
|
257
|
+
{(loading || replacing) && (
|
|
258
|
+
<span className="flex items-center gap-1">
|
|
259
|
+
<Loader2 className="size-2.5 animate-spin" />
|
|
260
|
+
{replacing ? "Replacing…" : "Searching…"}
|
|
261
|
+
</span>
|
|
262
|
+
)}
|
|
263
|
+
{!loading && !replacing && replaceCount !== null && (
|
|
264
|
+
<span className="text-green-500">{replaceCount} replacement{replaceCount !== 1 ? "s" : ""} made</span>
|
|
265
|
+
)}
|
|
266
|
+
{!loading && !replacing && replaceCount === null && !regexError && query.length >= 2 && results.length === 0 && <span>No results</span>}
|
|
267
|
+
{!loading && !replacing && replaceCount === null && total > 0 && (
|
|
268
|
+
<span>{total} result{total !== 1 ? "s" : ""} in {results.length} file{results.length !== 1 ? "s" : ""}</span>
|
|
269
|
+
)}
|
|
270
|
+
</div>
|
|
271
|
+
</div>
|
|
272
|
+
|
|
273
|
+
{/* Results */}
|
|
274
|
+
<div className="flex-1 overflow-y-auto min-h-0">
|
|
275
|
+
{results.map((r) => {
|
|
276
|
+
const isCollapsed = collapsed.has(r.file);
|
|
277
|
+
const fileName = r.file.split("/").pop() ?? r.file;
|
|
278
|
+
const dirPath = r.file.includes("/") ? r.file.slice(0, r.file.lastIndexOf("/")) : "";
|
|
279
|
+
return (
|
|
280
|
+
<div key={r.file}>
|
|
281
|
+
<button
|
|
282
|
+
onClick={() => toggleCollapse(r.file)}
|
|
283
|
+
className="w-full flex items-center gap-1 px-2 py-1 hover:bg-muted/50 text-left"
|
|
284
|
+
>
|
|
285
|
+
{isCollapsed ? <ChevronRight className="size-3 shrink-0 text-text-subtle" /> : <ChevronDown className="size-3 shrink-0 text-text-subtle" />}
|
|
286
|
+
<FileText className="size-3 shrink-0 text-text-subtle" />
|
|
287
|
+
<span className="text-xs font-medium text-foreground truncate">{fileName}</span>
|
|
288
|
+
<span className="text-[10px] text-text-subtle truncate flex-1 min-w-0 ml-1">{dirPath}</span>
|
|
289
|
+
<span className="text-[10px] text-text-subtle shrink-0 ml-1 bg-muted px-1 rounded">{r.matches.length}</span>
|
|
290
|
+
</button>
|
|
291
|
+
|
|
292
|
+
{!isCollapsed && r.matches.map((m) => (
|
|
293
|
+
<button
|
|
294
|
+
key={`${r.file}-${m.lineNum}`}
|
|
295
|
+
onClick={() => openFile(r.file, m.lineNum)}
|
|
296
|
+
className="w-full flex items-start gap-2 pl-7 pr-2 py-0.5 hover:bg-primary/10 text-left"
|
|
297
|
+
>
|
|
298
|
+
<span className="text-[10px] text-text-subtle shrink-0 w-7 text-right pt-px">{m.lineNum}</span>
|
|
299
|
+
<span className="text-xs text-text-secondary truncate font-mono leading-4">
|
|
300
|
+
<HighlightMatch text={m.content.trimStart()} re={highlightRe} />
|
|
301
|
+
</span>
|
|
302
|
+
</button>
|
|
303
|
+
))}
|
|
304
|
+
</div>
|
|
305
|
+
);
|
|
306
|
+
})}
|
|
307
|
+
</div>
|
|
308
|
+
</div>
|
|
309
|
+
);
|
|
310
|
+
}
|
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
import { useCallback, useRef } from "react";
|
|
2
|
-
import { PanelLeftClose, PanelLeftOpen, FolderOpen, GitBranch, Settings, Database } from "lucide-react";
|
|
2
|
+
import { PanelLeftClose, PanelLeftOpen, FolderOpen, GitBranch, Settings, Database, Search } from "lucide-react";
|
|
3
3
|
import { useProjectStore } from "@/stores/project-store";
|
|
4
4
|
import { useSettingsStore, type SidebarActiveTab } from "@/stores/settings-store";
|
|
5
5
|
import { FileTree } from "@/components/explorer/file-tree";
|
|
6
6
|
import { GitStatusPanel } from "@/components/git/git-status-panel";
|
|
7
7
|
import { SettingsTab } from "@/components/settings/settings-tab";
|
|
8
8
|
import { DatabaseSidebar } from "@/components/database/database-sidebar";
|
|
9
|
+
import { SearchPanel } from "@/components/explorer/search-panel";
|
|
9
10
|
import { cn } from "@/lib/utils";
|
|
10
11
|
|
|
11
12
|
const TABS: { id: SidebarActiveTab; label: string; icon: React.ElementType }[] = [
|
|
12
13
|
{ id: "explorer", label: "Explorer", icon: FolderOpen },
|
|
14
|
+
{ id: "search", label: "Search", icon: Search },
|
|
13
15
|
{ id: "git", label: "Git", icon: GitBranch },
|
|
14
16
|
{ id: "database", label: "Database", icon: Database },
|
|
15
17
|
{ id: "settings", label: "Settings", icon: Settings },
|
|
@@ -124,6 +126,9 @@ export function Sidebar() {
|
|
|
124
126
|
{sidebarActiveTab === "git" && (
|
|
125
127
|
<GitStatusPanel metadata={{ projectName: activeProject?.name }} />
|
|
126
128
|
)}
|
|
129
|
+
{sidebarActiveTab === "search" && (
|
|
130
|
+
<SearchPanel />
|
|
131
|
+
)}
|
|
127
132
|
{sidebarActiveTab === "database" && (
|
|
128
133
|
<DatabaseSidebar />
|
|
129
134
|
)}
|
|
@@ -116,6 +116,15 @@ export function useGlobalKeybindings() {
|
|
|
116
116
|
return;
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
+
// Open search (sidebar)
|
|
120
|
+
if (match(e, "open-search")) {
|
|
121
|
+
e.preventDefault();
|
|
122
|
+
const settings = useSettingsStore.getState();
|
|
123
|
+
if (settings.sidebarCollapsed) settings.toggleSidebar();
|
|
124
|
+
settings.setSidebarActiveTab("search");
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
119
128
|
// Switch project 1-9
|
|
120
129
|
for (let i = 1; i <= 9; i++) {
|
|
121
130
|
if (match(e, `switch-project-${i}`)) {
|
|
@@ -35,6 +35,7 @@ export const KEY_ACTIONS: KeyAction[] = [
|
|
|
35
35
|
{ id: "open-settings", label: "Open Settings", category: "tabs", defaultKey: "Mod+," },
|
|
36
36
|
{ id: "open-git-graph", label: "Git Graph", category: "tabs", defaultKey: "Mod+Shift+G" },
|
|
37
37
|
{ id: "open-git-status", label: "Git Status (sidebar)", category: "tabs", defaultKey: "Mod+Shift+E" },
|
|
38
|
+
{ id: "open-search", label: "Search Files (sidebar)", category: "tabs", defaultKey: "Mod+Shift+F" },
|
|
38
39
|
// Projects — Mod+1..9
|
|
39
40
|
...Array.from({ length: 9 }, (_, i) => ({
|
|
40
41
|
id: `switch-project-${i + 1}`,
|
|
@@ -2,7 +2,7 @@ import { create } from "zustand";
|
|
|
2
2
|
|
|
3
3
|
export type Theme = "light" | "dark" | "system";
|
|
4
4
|
export type GitStatusViewMode = "flat" | "tree";
|
|
5
|
-
export type SidebarActiveTab = "explorer" | "git" | "settings" | "database";
|
|
5
|
+
export type SidebarActiveTab = "explorer" | "git" | "settings" | "database" | "search";
|
|
6
6
|
|
|
7
7
|
const STORAGE_KEY = "ppm-settings";
|
|
8
8
|
|
|
@@ -78,7 +78,7 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
|
|
|
78
78
|
sidebarWidth: _initial.sidebarWidth ?? 280,
|
|
79
79
|
gitStatusViewMode: _initial.gitStatusViewMode === "flat" ? "flat" : "tree",
|
|
80
80
|
wordWrap: _initial.wordWrap ?? false,
|
|
81
|
-
sidebarActiveTab: (["git", "settings", "database"] as SidebarActiveTab[]).includes(_initial.sidebarActiveTab as SidebarActiveTab) ? _initial.sidebarActiveTab! : "explorer",
|
|
81
|
+
sidebarActiveTab: (["git", "settings", "database", "search"] as SidebarActiveTab[]).includes(_initial.sidebarActiveTab as SidebarActiveTab) ? _initial.sidebarActiveTab! : "explorer",
|
|
82
82
|
deviceName: null,
|
|
83
83
|
version: null,
|
|
84
84
|
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
import{i as e,t}from"./react-CYzKIDNi.js";import{n,t as r}from"./jsx-runtime-wQxeESYQ.js";import{a as i,t as a}from"./tab-store-D7tRt0VT.js";import{n as o}from"./settings-store-oxI7uvce.js";import{t as s}from"./utils-DBpa1UZX.js";import{i as c,r as l,t as u}from"./api-client-B0aMOJxF.js";import{A as d}from"./index-DJ9J8ofO.js";import{t as f}from"./markdown-renderer-CxhgCtxp.js";import{n as p,t as m}from"./use-monaco-theme-CeYne1fd.js";var h=n(`file-exclamation-point`,[[`path`,{d:`M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z`,key:`1oefj6`}],[`path`,{d:`M12 9v4`,key:`juzpu7`}],[`path`,{d:`M12 17h.01`,key:`p32p05`}]]),g=e(t(),1),_=r(),v=new Set([`png`,`jpg`,`jpeg`,`gif`,`webp`,`svg`,`ico`]),y=new Set([`db`,`sqlite`,`sqlite3`]);function b(e){return e.split(`.`).pop()?.toLowerCase()??``}function x(e){return{js:`javascript`,jsx:`javascript`,ts:`typescript`,tsx:`typescript`,py:`python`,html:`html`,css:`css`,scss:`scss`,json:`json`,md:`markdown`,mdx:`markdown`,yaml:`yaml`,yml:`yaml`,sh:`shell`,bash:`shell`}[b(e)]??`plaintext`}function S({metadata:e,tabId:t}){let n=e?.filePath,r=e?.projectName,[i,l]=(0,g.useState)(null),[f,S]=(0,g.useState)(`utf-8`),[E,D]=(0,g.useState)(!0),[O,k]=(0,g.useState)(null),[A,j]=(0,g.useState)(!1),M=(0,g.useRef)(null),N=(0,g.useRef)(``),P=(0,g.useRef)(null),{tabs:F,updateTab:I}=a(),{wordWrap:L,toggleWordWrap:R}=o(),z=m(),B=F.find(e=>e.id===t),V=n?b(n):``,H=v.has(V),U=V===`pdf`,W=y.has(V),G=V===`md`||V===`mdx`,[K,q]=(0,g.useState)(`preview`);(0,g.useEffect)(()=>{W&&t&&I(t,{type:`sqlite`})},[W,t,I]);let J=n?/^(\/|[A-Za-z]:[/\\])/.test(n):!1;(0,g.useEffect)(()=>{if(!n||!J&&!r)return;if(H||U){D(!1);return}D(!0),k(null);let e=J?`/api/fs/read?path=${encodeURIComponent(n)}`:`${c(r)}/files/read?path=${encodeURIComponent(n)}`;return u.get(e).then(e=>{l(e.content),e.encoding&&S(e.encoding),N.current=e.content,D(!1)}).catch(e=>{k(e instanceof Error?e.message:`Failed to load file`),D(!1)}),()=>{M.current&&clearTimeout(M.current)}},[n,r,H,U,J]),(0,g.useEffect)(()=>{if(!B)return;let e=n?s(n):`Untitled`,t=A?`${e} \u25CF`:e;B.title!==t&&I(B.id,{title:t})},[A]);let Y=(0,g.useCallback)(async e=>{if(n&&!(!J&&!r))try{J?await u.put(`/api/fs/write`,{path:n,content:e}):await u.put(`${c(r)}/files/write`,{path:n,content:e}),j(!1)}catch{}},[n,r,J]);function X(e){let t=e??``;l(t),N.current=t,j(!0),M.current&&clearTimeout(M.current),M.current=setTimeout(()=>Y(N.current),1e3)}let Z=(0,g.useCallback)((e,t)=>{P.current=e,e.addCommand(t.KeyMod.Alt|t.KeyCode.KeyZ,()=>o.getState().toggleWordWrap()),t.languages.typescript.typescriptDefaults.setDiagnosticsOptions({noSemanticValidation:!0,noSyntaxValidation:!0,noSuggestionDiagnostics:!0}),t.languages.typescript.javascriptDefaults.setDiagnosticsOptions({noSemanticValidation:!0,noSyntaxValidation:!0,noSuggestionDiagnostics:!0})},[]);return!n||!J&&!r?(0,_.jsx)(`div`,{className:`flex items-center justify-center h-full text-text-secondary text-sm`,children:`No file selected.`}):E?(0,_.jsxs)(`div`,{className:`flex items-center justify-center h-full gap-2 text-text-secondary`,children:[(0,_.jsx)(d,{className:`size-5 animate-spin`}),(0,_.jsx)(`span`,{className:`text-sm`,children:`Loading file...`})]}):O?(0,_.jsx)(`div`,{className:`flex items-center justify-center h-full text-error text-sm`,children:O}):H?(0,_.jsx)(w,{filePath:n,projectName:r}):U?(0,_.jsx)(T,{filePath:n,projectName:r}):f===`base64`?(0,_.jsxs)(`div`,{className:`flex flex-col items-center justify-center h-full gap-3 text-text-secondary`,children:[(0,_.jsx)(h,{className:`size-10 text-text-subtle`}),(0,_.jsx)(`p`,{className:`text-sm`,children:`This file is a binary format and cannot be displayed.`}),(0,_.jsx)(`p`,{className:`text-xs text-text-subtle`,children:n})]}):(0,_.jsx)(`div`,{className:`flex flex-col h-full w-full overflow-hidden`,children:G&&K===`preview`?(0,_.jsx)(C,{content:i??``}):(0,_.jsx)(`div`,{className:`flex-1 overflow-hidden`,children:(0,_.jsx)(p,{height:`100%`,language:x(n),value:i??``,onChange:X,onMount:Z,theme:z,options:{fontSize:13,fontFamily:`Menlo, Monaco, Consolas, monospace`,wordWrap:L?`on`:`off`,minimap:{enabled:!1},scrollBeyondLastLine:!1,automaticLayout:!0,lineNumbers:`on`,folding:!0,bracketPairColorization:{enabled:!0}},loading:(0,_.jsx)(d,{className:`size-5 animate-spin text-text-subtle`})})})})}function C({content:e}){return(0,_.jsx)(f,{content:e,className:`flex-1 overflow-auto p-4`})}function w({filePath:e,projectName:t}){let[n,r]=(0,g.useState)(null),[i,a]=(0,g.useState)(!1);return(0,g.useEffect)(()=>{let n,i=`${c(t)}/files/raw?path=${encodeURIComponent(e)}`,o=l();return fetch(i,{headers:o?{Authorization:`Bearer ${o}`}:{}}).then(e=>{if(!e.ok)throw Error(`Failed`);return e.blob()}).then(e=>{let t=URL.createObjectURL(e);n=t,r(t)}).catch(()=>a(!0)),()=>{n&&URL.revokeObjectURL(n)}},[e,t]),i?(0,_.jsxs)(`div`,{className:`flex flex-col items-center justify-center h-full gap-3 text-text-secondary`,children:[(0,_.jsx)(h,{className:`size-10 text-text-subtle`}),(0,_.jsx)(`p`,{className:`text-sm`,children:`Failed to load image.`})]}):n?(0,_.jsx)(`div`,{className:`flex items-center justify-center h-full p-4 bg-surface overflow-auto`,children:(0,_.jsx)(`img`,{src:n,alt:e,className:`max-w-full max-h-full object-contain`})}):(0,_.jsx)(`div`,{className:`flex items-center justify-center h-full`,children:(0,_.jsx)(d,{className:`size-5 animate-spin text-text-subtle`})})}function T({filePath:e,projectName:t}){let[n,r]=(0,g.useState)(null),[a,o]=(0,g.useState)(!1);(0,g.useEffect)(()=>{let n,i=`${c(t)}/files/raw?path=${encodeURIComponent(e)}`,a=l();return fetch(i,{headers:a?{Authorization:`Bearer ${a}`}:{}}).then(e=>{if(!e.ok)throw Error(`Failed`);return e.blob()}).then(e=>{let t=URL.createObjectURL(new Blob([e],{type:`application/pdf`}));n=t,r(t)}).catch(()=>o(!0)),()=>{n&&URL.revokeObjectURL(n)}},[e,t]);let s=(0,g.useCallback)(()=>{n&&window.open(n,`_blank`)},[n]);return a?(0,_.jsxs)(`div`,{className:`flex flex-col items-center justify-center h-full gap-3 text-text-secondary`,children:[(0,_.jsx)(h,{className:`size-10 text-text-subtle`}),(0,_.jsx)(`p`,{className:`text-sm`,children:`Failed to load PDF.`})]}):n?(0,_.jsxs)(`div`,{className:`flex flex-col h-full`,children:[(0,_.jsxs)(`div`,{className:`flex items-center justify-between px-3 py-1.5 border-b border-border bg-background shrink-0`,children:[(0,_.jsx)(`span`,{className:`text-xs text-text-secondary truncate`,children:e}),(0,_.jsxs)(`button`,{onClick:s,className:`flex items-center gap-1 text-xs text-text-secondary hover:text-text-primary transition-colors`,children:[(0,_.jsx)(i,{className:`size-3`}),` Open in new tab`]})]}),(0,_.jsx)(`iframe`,{src:n,title:e,className:`flex-1 w-full border-none`})]}):(0,_.jsx)(`div`,{className:`flex items-center justify-center h-full`,children:(0,_.jsx)(d,{className:`size-5 animate-spin text-text-subtle`})})}export{S as CodeEditor};
|