@hienlh/ppm 0.5.2 → 0.5.4
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 +32 -0
- package/dist/web/assets/{api-client-ANLU-Irq.js → api-client-BxCvlogn.js} +1 -1
- package/dist/web/assets/chat-tab-AwRs7rWS.js +7 -0
- package/dist/web/assets/code-editor-BviTme00.js +1 -0
- package/dist/web/assets/diff-viewer-CCZM_VBl.js +4 -0
- package/dist/web/assets/git-graph-UCZZ6fX6.js +1 -0
- package/dist/web/assets/index-BxHR8fUA.css +2 -0
- package/dist/web/assets/index-yvVRZ65D.js +21 -0
- package/dist/web/assets/{input-D-F4ITU0.js → input-Bzyi1GeB.js} +1 -1
- package/dist/web/assets/{jsx-runtime-B4BJKQ1u.js → jsx-runtime-Bzk8w7Zh.js} +1 -1
- package/dist/web/assets/markdown-renderer-DzVh1Ft8.js +59 -0
- package/dist/web/assets/{rotate-ccw-BesidNnx.js → rotate-ccw-ZqeedZLA.js} +1 -1
- package/dist/web/assets/settings-store-DikslxSJ.js +1 -0
- package/dist/web/assets/settings-tab-C-AGuxll.js +1 -0
- package/dist/web/assets/tab-store-BNgVKR5w.js +1 -0
- package/dist/web/assets/terminal-tab-CnbdkUFt.js +36 -0
- package/dist/web/assets/{use-monaco-theme-CsNwoeyj.js → use-monaco-theme-BFv4d2_j.js} +2 -2
- package/dist/web/assets/{utils-bntUtdc7.js → utils-EM9hC5pN.js} +1 -1
- package/dist/web/index.html +8 -9
- package/dist/web/sw.js +1 -1
- package/package.json +1 -1
- package/src/cli/commands/init.ts +2 -2
- package/src/cli/commands/status.ts +85 -1
- package/src/cli/commands/stop.ts +56 -2
- package/src/index.ts +4 -2
- package/src/providers/claude-agent-sdk.ts +0 -4
- package/src/server/index.ts +81 -20
- package/src/services/config.service.ts +11 -1
- package/src/web/components/chat/attachment-chips.tsx +1 -1
- package/src/web/components/chat/chat-history-bar.tsx +0 -3
- package/src/web/components/chat/message-input.tsx +13 -14
- package/src/web/components/chat/message-list.tsx +5 -4
- package/src/web/components/chat/tool-cards.tsx +3 -6
- package/src/web/components/editor/code-editor.tsx +2 -1
- package/src/web/components/editor/diff-viewer.tsx +43 -22
- package/src/web/components/explorer/file-tree.tsx +3 -3
- package/src/web/components/git/git-graph.tsx +2 -1
- package/src/web/components/git/git-status-panel.tsx +166 -89
- package/src/web/components/layout/command-palette.tsx +2 -1
- package/src/web/components/layout/mobile-drawer.tsx +2 -2
- package/src/web/components/layout/mobile-nav.tsx +1 -1
- package/src/web/components/layout/panel-layout.tsx +16 -16
- package/src/web/components/layout/split-drop-overlay.tsx +3 -3
- package/src/web/components/shared/markdown-renderer.tsx +16 -10
- package/src/web/hooks/use-terminal.ts +66 -23
- package/src/web/lib/utils.ts +5 -0
- package/src/web/stores/panel-store.ts +15 -14
- package/src/web/stores/panel-utils.ts +12 -10
- package/src/web/stores/settings-store.ts +1 -1
- package/dist/web/assets/chat-tab-CWBzraGA.js +0 -7
- package/dist/web/assets/code-editor-C4JSoO8E.js +0 -1
- package/dist/web/assets/diff-viewer-BdxT3tDC.js +0 -4
- package/dist/web/assets/git-graph-C7Rc_ZjF.js +0 -1
- package/dist/web/assets/index-DHOHCLrc.js +0 -21
- package/dist/web/assets/index-DhsWierF.css +0 -2
- package/dist/web/assets/markdown-renderer-Cv9PPnXe.js +0 -59
- package/dist/web/assets/react-WvgCEYPV.js +0 -1
- package/dist/web/assets/settings-store-CGtTcr8r.js +0 -1
- package/dist/web/assets/settings-tab-DYv7J4Vw.js +0 -1
- package/dist/web/assets/tab-store-Dq1kMOkJ.js +0 -1
- package/dist/web/assets/terminal-tab-BeYE7Lrg.js +0 -36
package/src/server/index.ts
CHANGED
|
@@ -254,14 +254,32 @@ export async function startServer(options: {
|
|
|
254
254
|
const tunnelLog = resolve(ppmDir, "tunnel.log");
|
|
255
255
|
// Truncate old log so we only match the new tunnel URL
|
|
256
256
|
writeFs(tunnelLog, "");
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
257
|
+
|
|
258
|
+
if (process.platform === "win32") {
|
|
259
|
+
// Windows: use PowerShell for detached tunnel process
|
|
260
|
+
const psCmd = [
|
|
261
|
+
`$p = Start-Process -PassThru -WindowStyle Hidden`,
|
|
262
|
+
`-FilePath '${bin.replace(/\\/g, "\\\\")}'`,
|
|
263
|
+
`-ArgumentList 'tunnel','--url','http://localhost:${port}'`,
|
|
264
|
+
`-RedirectStandardError '${tunnelLog.replace(/\\/g, "\\\\")}'`,
|
|
265
|
+
`; Write-Output $p.Id`,
|
|
266
|
+
].join(" ");
|
|
267
|
+
const result = Bun.spawnSync({
|
|
268
|
+
cmd: ["powershell", "-NoProfile", "-Command", psCmd],
|
|
269
|
+
stdout: "pipe", stderr: "pipe",
|
|
270
|
+
});
|
|
271
|
+
tunnelPid = parseInt(result.stdout.toString().trim(), 10);
|
|
272
|
+
if (isNaN(tunnelPid)) tunnelPid = null;
|
|
273
|
+
} else {
|
|
274
|
+
const tfd = openFd(tunnelLog, "a");
|
|
275
|
+
const tunnelProc = Bun.spawn({
|
|
276
|
+
cmd: [bin, "tunnel", "--url", `http://localhost:${port}`],
|
|
277
|
+
stdio: ["ignore", "ignore", tfd],
|
|
278
|
+
env: process.env,
|
|
279
|
+
});
|
|
280
|
+
tunnelProc.unref();
|
|
281
|
+
tunnelPid = tunnelProc.pid;
|
|
282
|
+
}
|
|
265
283
|
|
|
266
284
|
// Parse URL from tunnel.log (poll stderr output)
|
|
267
285
|
const urlRegex = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/;
|
|
@@ -282,22 +300,65 @@ export async function startServer(options: {
|
|
|
282
300
|
const { openSync } = await import("node:fs");
|
|
283
301
|
const logFile = resolve(ppmDir, "ppm.log");
|
|
284
302
|
const logFd = openSync(logFile, "a");
|
|
285
|
-
const
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
303
|
+
const { resolve: resolvePath } = await import("node:path");
|
|
304
|
+
const script = resolvePath(import.meta.dir, "index.ts");
|
|
305
|
+
const args = ["__serve__", String(port), host, options.config ?? ""];
|
|
306
|
+
|
|
307
|
+
let childPid: number;
|
|
308
|
+
|
|
309
|
+
if (process.platform === "win32") {
|
|
310
|
+
// Windows: Bun.spawn child may die when parent exits (same job object).
|
|
311
|
+
// Use PowerShell Start-Process to create a truly detached process.
|
|
312
|
+
const bunExe = process.execPath.replace(/\\/g, "\\\\");
|
|
313
|
+
const argStr = ["run", script, ...args].map((a) => `'${a}'`).join(",");
|
|
314
|
+
const psCmd = [
|
|
315
|
+
`$p = Start-Process -PassThru -WindowStyle Hidden`,
|
|
316
|
+
`-FilePath '${bunExe}'`,
|
|
317
|
+
`-ArgumentList ${argStr}`,
|
|
318
|
+
`-RedirectStandardOutput '${logFile.replace(/\\/g, "\\\\")}'`,
|
|
319
|
+
`-RedirectStandardError '${logFile.replace(/\\/g, "\\\\")}'`,
|
|
320
|
+
`; Write-Output $p.Id`,
|
|
321
|
+
].join(" ");
|
|
322
|
+
const result = Bun.spawnSync({
|
|
323
|
+
cmd: ["powershell", "-NoProfile", "-Command", psCmd],
|
|
324
|
+
stdout: "pipe",
|
|
325
|
+
stderr: "pipe",
|
|
326
|
+
});
|
|
327
|
+
childPid = parseInt(result.stdout.toString().trim(), 10);
|
|
328
|
+
if (isNaN(childPid)) {
|
|
329
|
+
console.error(" ✗ Failed to start daemon on Windows.");
|
|
330
|
+
console.error(` ${result.stderr.toString().trim()}`);
|
|
331
|
+
console.error(" Try: ppm start -f (foreground mode)");
|
|
332
|
+
process.exit(1);
|
|
333
|
+
}
|
|
334
|
+
} else {
|
|
335
|
+
// macOS/Linux: Bun.spawn + unref works fine
|
|
336
|
+
const child = Bun.spawn({
|
|
337
|
+
cmd: [process.execPath, "run", script, ...args],
|
|
338
|
+
stdio: ["ignore", logFd, logFd],
|
|
339
|
+
env: process.env,
|
|
340
|
+
});
|
|
341
|
+
child.unref();
|
|
342
|
+
childPid = child.pid;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Verify daemon is alive after brief startup
|
|
346
|
+
await Bun.sleep(500);
|
|
347
|
+
let alive = false;
|
|
348
|
+
try { process.kill(childPid, 0); alive = true; } catch {}
|
|
349
|
+
if (!alive) {
|
|
350
|
+
console.error(" ✗ Daemon exited immediately after start.");
|
|
351
|
+
console.error(" Check logs: ppm logs");
|
|
352
|
+
console.error(" Or try: ppm start -f (foreground mode)");
|
|
353
|
+
process.exit(1);
|
|
354
|
+
}
|
|
294
355
|
|
|
295
356
|
// Write status file with both PIDs
|
|
296
|
-
const status = { pid:
|
|
357
|
+
const status = { pid: childPid, port, host, shareUrl, tunnelPid };
|
|
297
358
|
writeFileSync(statusFile, JSON.stringify(status));
|
|
298
|
-
writeFileSync(pidFile, String(
|
|
359
|
+
writeFileSync(pidFile, String(childPid));
|
|
299
360
|
|
|
300
|
-
console.log(`\n PPM v${VERSION} daemon started (PID: ${
|
|
361
|
+
console.log(`\n PPM v${VERSION} daemon started (PID: ${childPid})\n`);
|
|
301
362
|
console.log(` ➜ Local: http://localhost:${port}/`);
|
|
302
363
|
if (shareUrl) {
|
|
303
364
|
console.log(` ➜ Share: ${shareUrl}`);
|
|
@@ -23,12 +23,18 @@ class ConfigService {
|
|
|
23
23
|
].filter(Boolean) as string[];
|
|
24
24
|
|
|
25
25
|
for (const p of searchPaths) {
|
|
26
|
-
|
|
26
|
+
const found = existsSync(p);
|
|
27
|
+
if (!found) {
|
|
28
|
+
console.log(`[config] Not found: ${p}`);
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
try {
|
|
27
32
|
const raw = readFileSync(p, "utf-8");
|
|
28
33
|
const parsed = yaml.load(raw) as Partial<PpmConfig> | null;
|
|
29
34
|
if (parsed) {
|
|
30
35
|
this.config = { ...structuredClone(DEFAULT_CONFIG), ...parsed };
|
|
31
36
|
this.configPath = p;
|
|
37
|
+
console.log(`[config] Loaded from: ${p}`);
|
|
32
38
|
// Auto-generate token if auth enabled but token is empty
|
|
33
39
|
if (this.config.auth.enabled && !this.config.auth.token) {
|
|
34
40
|
this.config.auth.token = randomBytes(16).toString("hex");
|
|
@@ -40,10 +46,14 @@ class ConfigService {
|
|
|
40
46
|
}
|
|
41
47
|
return this.config;
|
|
42
48
|
}
|
|
49
|
+
console.log(`[config] Empty or invalid YAML: ${p}`);
|
|
50
|
+
} catch (err) {
|
|
51
|
+
console.error(`[config] Error reading ${p}:`, (err as Error).message);
|
|
43
52
|
}
|
|
44
53
|
}
|
|
45
54
|
|
|
46
55
|
// No config found — create default
|
|
56
|
+
console.log(`[config] No config found, creating default at ${GLOBAL_CONFIG_PATH}`);
|
|
47
57
|
this.config = this.createDefault();
|
|
48
58
|
return this.config;
|
|
49
59
|
}
|
|
@@ -10,7 +10,7 @@ export function AttachmentChips({ attachments, onRemove }: AttachmentChipsProps)
|
|
|
10
10
|
if (attachments.length === 0) return null;
|
|
11
11
|
|
|
12
12
|
return (
|
|
13
|
-
<div className="flex flex-wrap gap-1.5 px-
|
|
13
|
+
<div className="flex flex-wrap gap-1.5 px-2 md:px-4 pt-2">
|
|
14
14
|
{attachments.map((att) => (
|
|
15
15
|
<div
|
|
16
16
|
key={att.id}
|
|
@@ -149,9 +149,6 @@ export function ChatHistoryBar({
|
|
|
149
149
|
<span className={pctColor(contextWindowPct)}>Ctx:{contextWindowPct}%</span>
|
|
150
150
|
</>
|
|
151
151
|
)}
|
|
152
|
-
{lastFetchedAt && (
|
|
153
|
-
<span className="text-text-subtle/50 font-normal text-[9px] ml-0.5">{relativeTime(lastFetchedAt)}</span>
|
|
154
|
-
)}
|
|
155
152
|
</button>
|
|
156
153
|
|
|
157
154
|
{/* Spacer */}
|
|
@@ -57,6 +57,7 @@ export function MessageInput({
|
|
|
57
57
|
const [value, setValue] = useState(initialValue ?? "");
|
|
58
58
|
const [attachments, setAttachments] = useState<ChatAttachment[]>([]);
|
|
59
59
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
60
|
+
const mobileTextareaRef = useRef<HTMLTextAreaElement>(null);
|
|
60
61
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
61
62
|
const slashItemsRef = useRef<SlashItem[]>([]);
|
|
62
63
|
const fileItemsRef = useRef<FileNode[]>([]);
|
|
@@ -238,7 +239,7 @@ export function MessageInput({
|
|
|
238
239
|
);
|
|
239
240
|
});
|
|
240
241
|
}
|
|
241
|
-
textareaRef.current?.focus();
|
|
242
|
+
(mobileTextareaRef.current ?? textareaRef.current)?.focus();
|
|
242
243
|
},
|
|
243
244
|
[uploadFile],
|
|
244
245
|
);
|
|
@@ -266,9 +267,8 @@ export function MessageInput({
|
|
|
266
267
|
if (att.previewUrl) URL.revokeObjectURL(att.previewUrl);
|
|
267
268
|
}
|
|
268
269
|
setAttachments([]);
|
|
269
|
-
if (textareaRef.current)
|
|
270
|
-
|
|
271
|
-
}
|
|
270
|
+
if (textareaRef.current) textareaRef.current.style.height = "auto";
|
|
271
|
+
if (mobileTextareaRef.current) mobileTextareaRef.current.style.height = "auto";
|
|
272
272
|
}, [value, attachments, disabled, onSend, onSlashStateChange, onFileStateChange]);
|
|
273
273
|
|
|
274
274
|
const handleKeyDown = useCallback(
|
|
@@ -320,8 +320,8 @@ export function MessageInput({
|
|
|
320
320
|
[updatePickerState],
|
|
321
321
|
);
|
|
322
322
|
|
|
323
|
-
const handleInput = useCallback(() => {
|
|
324
|
-
const el = textareaRef.current;
|
|
323
|
+
const handleInput = useCallback((e?: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
324
|
+
const el = e?.target ?? textareaRef.current;
|
|
325
325
|
if (!el) return;
|
|
326
326
|
el.style.height = "auto";
|
|
327
327
|
el.style.height = Math.min(el.scrollHeight, 160) + "px";
|
|
@@ -382,14 +382,13 @@ export function MessageInput({
|
|
|
382
382
|
|
|
383
383
|
return (
|
|
384
384
|
<div className="p-2 md:p-3 bg-background">
|
|
385
|
-
{/* Attachment chips (above input) */}
|
|
386
|
-
<AttachmentChips attachments={attachments} onRemove={removeAttachment} />
|
|
387
|
-
|
|
388
385
|
{/* Rounded input container */}
|
|
389
386
|
<div
|
|
390
387
|
className="border border-border rounded-xl md:rounded-2xl bg-surface shadow-sm cursor-text"
|
|
391
|
-
onClick={() => !disabled && textareaRef.current?.focus()}
|
|
388
|
+
onClick={() => !disabled && (mobileTextareaRef.current ?? textareaRef.current)?.focus()}
|
|
392
389
|
>
|
|
390
|
+
{/* Attachment chips (inside container, aligned with input) */}
|
|
391
|
+
<AttachmentChips attachments={attachments} onRemove={removeAttachment} />
|
|
393
392
|
{/* Mobile: single row — attach + textarea + send */}
|
|
394
393
|
<div className="flex items-end gap-1 md:hidden px-2 py-2">
|
|
395
394
|
<button
|
|
@@ -402,9 +401,9 @@ export function MessageInput({
|
|
|
402
401
|
<Paperclip className="size-4" />
|
|
403
402
|
</button>
|
|
404
403
|
<textarea
|
|
405
|
-
ref={
|
|
404
|
+
ref={mobileTextareaRef}
|
|
406
405
|
value={value}
|
|
407
|
-
onChange={(e) => { handleChange(e.target.value); handleInput(); }}
|
|
406
|
+
onChange={(e) => { handleChange(e.target.value); handleInput(e); }}
|
|
408
407
|
onKeyDown={handleKeyDown}
|
|
409
408
|
onPaste={handlePaste}
|
|
410
409
|
onDrop={handleDrop}
|
|
@@ -412,7 +411,7 @@ export function MessageInput({
|
|
|
412
411
|
placeholder={isStreaming ? "Follow-up..." : "Ask anything..."}
|
|
413
412
|
disabled={disabled}
|
|
414
413
|
rows={1}
|
|
415
|
-
className="flex-1 resize-none bg-transparent py-1.5 text-sm text-foreground placeholder:text-text-subtle focus:outline-none disabled:opacity-50 max-h-
|
|
414
|
+
className="flex-1 resize-none bg-transparent py-1.5 text-sm text-foreground placeholder:text-text-subtle focus:outline-none disabled:opacity-50 max-h-20"
|
|
416
415
|
/>
|
|
417
416
|
{showCancel ? (
|
|
418
417
|
<button
|
|
@@ -439,7 +438,7 @@ export function MessageInput({
|
|
|
439
438
|
<textarea
|
|
440
439
|
ref={textareaRef}
|
|
441
440
|
value={value}
|
|
442
|
-
onChange={(e) => { handleChange(e.target.value); handleInput(); }}
|
|
441
|
+
onChange={(e) => { handleChange(e.target.value); handleInput(e); }}
|
|
443
442
|
onKeyDown={handleKeyDown}
|
|
444
443
|
onPaste={handlePaste}
|
|
445
444
|
onDrop={handleDrop}
|
|
@@ -5,6 +5,7 @@ import type { ChatMessage, ChatEvent } from "../../../types/chat";
|
|
|
5
5
|
import type { StreamingStatus } from "@/hooks/use-chat";
|
|
6
6
|
import { ToolCard } from "./tool-cards";
|
|
7
7
|
import { MarkdownRenderer } from "@/components/shared/markdown-renderer";
|
|
8
|
+
import { basename } from "@/lib/utils";
|
|
8
9
|
|
|
9
10
|
import {
|
|
10
11
|
AlertCircle,
|
|
@@ -164,7 +165,7 @@ function parseUserAttachments(content: string): { files: string[]; text: string
|
|
|
164
165
|
|
|
165
166
|
/** Build a preview URL for an uploaded file (served from /chat/uploads/:filename) */
|
|
166
167
|
function uploadPreviewUrl(filePath: string, projectName?: string): string {
|
|
167
|
-
const filename = filePath
|
|
168
|
+
const filename = basename(filePath);
|
|
168
169
|
// Use a generic project name — the upload route is project-scoped but files are global
|
|
169
170
|
return `/api/project/${encodeURIComponent(projectName ?? "_")}/chat/uploads/${encodeURIComponent(filename)}`;
|
|
170
171
|
}
|
|
@@ -195,13 +196,13 @@ function UserBubble({ content, projectName, onFork }: { content: string; project
|
|
|
195
196
|
<AuthImage
|
|
196
197
|
key={i}
|
|
197
198
|
src={uploadPreviewUrl(filePath, projectName)}
|
|
198
|
-
alt={filePath
|
|
199
|
+
alt={basename(filePath) || "image"}
|
|
199
200
|
/>
|
|
200
201
|
) : isPdfPath(filePath) ? (
|
|
201
202
|
<AuthFileLink
|
|
202
203
|
key={i}
|
|
203
204
|
src={uploadPreviewUrl(filePath, projectName)}
|
|
204
|
-
filename={filePath
|
|
205
|
+
filename={basename(filePath) || "document.pdf"}
|
|
205
206
|
mimeType="application/pdf"
|
|
206
207
|
/>
|
|
207
208
|
) : (
|
|
@@ -210,7 +211,7 @@ function UserBubble({ content, projectName, onFork }: { content: string; project
|
|
|
210
211
|
className="flex items-center gap-1.5 rounded-md border border-border bg-background/50 px-2 py-1 text-xs text-text-secondary"
|
|
211
212
|
>
|
|
212
213
|
<FileText className="size-3.5 shrink-0" />
|
|
213
|
-
<span className="truncate max-w-40">{filePath
|
|
214
|
+
<span className="truncate max-w-40">{basename(filePath)}</span>
|
|
214
215
|
</div>
|
|
215
216
|
),
|
|
216
217
|
)}
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
} from "lucide-react";
|
|
22
22
|
import type { ChatEvent } from "../../../types/chat";
|
|
23
23
|
import { useTabStore } from "@/stores/tab-store";
|
|
24
|
+
import { basename } from "@/lib/utils";
|
|
24
25
|
|
|
25
26
|
/** Extract tool name and input from a ChatEvent */
|
|
26
27
|
function extractToolInfo(tool: ChatEvent): { toolName: string; input: Record<string, unknown> } {
|
|
@@ -165,7 +166,7 @@ function ToolDetails({
|
|
|
165
166
|
if (!projectName) return;
|
|
166
167
|
openTab({
|
|
167
168
|
type: "editor",
|
|
168
|
-
title: filePath
|
|
169
|
+
title: basename(filePath),
|
|
169
170
|
metadata: { filePath, projectName },
|
|
170
171
|
projectId: projectName,
|
|
171
172
|
closable: true,
|
|
@@ -176,7 +177,7 @@ function ToolDetails({
|
|
|
176
177
|
const openEditDiff = (filePath: string, oldStr: string, newStr: string) => {
|
|
177
178
|
openTab({
|
|
178
179
|
type: "git-diff",
|
|
179
|
-
title: `Diff ${filePath
|
|
180
|
+
title: `Diff ${basename(filePath)}`,
|
|
180
181
|
metadata: { filePath, projectName, original: oldStr, modified: newStr },
|
|
181
182
|
projectId: projectName ?? null,
|
|
182
183
|
closable: true,
|
|
@@ -453,10 +454,6 @@ function MiniMarkdown({ content, maxHeight = "max-h-48" }: { content: string; ma
|
|
|
453
454
|
return <MarkdownRenderer content={content} className={`text-text-secondary overflow-auto ${maxHeight}`} />;
|
|
454
455
|
}
|
|
455
456
|
|
|
456
|
-
function basename(path?: string): string {
|
|
457
|
-
if (!path) return "";
|
|
458
|
-
return path.split("/").pop() ?? path;
|
|
459
|
-
}
|
|
460
457
|
|
|
461
458
|
function truncate(str?: string, max = 50): string {
|
|
462
459
|
if (!str) return "";
|
|
@@ -5,6 +5,7 @@ import { MarkdownRenderer } from "@/components/shared/markdown-renderer";
|
|
|
5
5
|
import { api, projectUrl, getAuthToken } from "@/lib/api-client";
|
|
6
6
|
import { useTabStore } from "@/stores/tab-store";
|
|
7
7
|
import { useSettingsStore } from "@/stores/settings-store";
|
|
8
|
+
import { basename } from "@/lib/utils";
|
|
8
9
|
import { useMonacoTheme } from "@/lib/use-monaco-theme";
|
|
9
10
|
import { Loader2, FileWarning, ExternalLink, Code, Eye, WrapText } from "lucide-react";
|
|
10
11
|
|
|
@@ -91,7 +92,7 @@ export function CodeEditor({ metadata, tabId }: CodeEditorProps) {
|
|
|
91
92
|
// Update tab title unsaved indicator
|
|
92
93
|
useEffect(() => {
|
|
93
94
|
if (!ownTab) return;
|
|
94
|
-
const baseName = filePath
|
|
95
|
+
const baseName = filePath ? basename(filePath) : "Untitled";
|
|
95
96
|
const newTitle = unsaved ? `${baseName} \u25CF` : baseName;
|
|
96
97
|
if (ownTab.title !== newTitle) updateTab(ownTab.id, { title: newTitle });
|
|
97
98
|
}, [unsaved]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useEffect, useState, useMemo } from "react";
|
|
1
|
+
import { useEffect, useState, useMemo, useRef } from "react";
|
|
2
2
|
import { DiffEditor } from "@monaco-editor/react";
|
|
3
3
|
import { api, projectUrl } from "@/lib/api-client";
|
|
4
4
|
import { useSettingsStore } from "@/stores/settings-store";
|
|
@@ -43,6 +43,20 @@ export function DiffViewer({ metadata }: DiffViewerProps) {
|
|
|
43
43
|
const { wordWrap, toggleWordWrap } = useSettingsStore();
|
|
44
44
|
const monacoTheme = useMonacoTheme();
|
|
45
45
|
|
|
46
|
+
// Measure container height — Monaco needs explicit pixel height on mobile
|
|
47
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
48
|
+
const [containerHeight, setContainerHeight] = useState<number | undefined>();
|
|
49
|
+
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
const el = containerRef.current;
|
|
52
|
+
if (!el) return;
|
|
53
|
+
const ro = new ResizeObserver(([entry]) => {
|
|
54
|
+
if (entry) setContainerHeight(Math.floor(entry.contentRect.height));
|
|
55
|
+
});
|
|
56
|
+
ro.observe(el);
|
|
57
|
+
return () => ro.disconnect();
|
|
58
|
+
}, []);
|
|
59
|
+
|
|
46
60
|
useEffect(() => {
|
|
47
61
|
if (isInline) return;
|
|
48
62
|
if (!projectName) return;
|
|
@@ -92,6 +106,10 @@ export function DiffViewer({ metadata }: DiffViewerProps) {
|
|
|
92
106
|
return langFile ? getMonacoLanguage(langFile) : "plaintext";
|
|
93
107
|
}, [filePath, file1, file2]);
|
|
94
108
|
|
|
109
|
+
// Force inline on mobile (<768px) since side-by-side is too narrow
|
|
110
|
+
const isMobile = typeof window !== "undefined" && window.innerWidth < 768;
|
|
111
|
+
const renderSideBySide = !isMobile && expandMode === "both";
|
|
112
|
+
|
|
95
113
|
if (!projectName && !isInline) {
|
|
96
114
|
return (
|
|
97
115
|
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
|
|
@@ -125,9 +143,6 @@ export function DiffViewer({ metadata }: DiffViewerProps) {
|
|
|
125
143
|
);
|
|
126
144
|
}
|
|
127
145
|
|
|
128
|
-
// expandMode left/right → inline diff (Monaco has no single-side mode)
|
|
129
|
-
const renderSideBySide = expandMode === "both";
|
|
130
|
-
|
|
131
146
|
const expandToggle = (
|
|
132
147
|
<div className="flex items-center gap-0.5 shrink-0">
|
|
133
148
|
<button type="button"
|
|
@@ -163,24 +178,30 @@ export function DiffViewer({ metadata }: DiffViewerProps) {
|
|
|
163
178
|
return (
|
|
164
179
|
<div className="flex flex-col h-full">
|
|
165
180
|
{/* Monaco DiffEditor */}
|
|
166
|
-
<div className="flex-1 overflow-hidden">
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
181
|
+
<div ref={containerRef} className="flex-1 overflow-hidden">
|
|
182
|
+
{containerHeight && containerHeight > 0 ? (
|
|
183
|
+
<DiffEditor
|
|
184
|
+
height={containerHeight}
|
|
185
|
+
language={language}
|
|
186
|
+
original={original}
|
|
187
|
+
modified={modified}
|
|
188
|
+
theme={monacoTheme}
|
|
189
|
+
options={{
|
|
190
|
+
fontSize: isMobile ? 11 : 13,
|
|
191
|
+
fontFamily: "Menlo, Monaco, Consolas, monospace",
|
|
192
|
+
wordWrap: isMobile ? "on" : wordWrap ? "on" : "off",
|
|
193
|
+
renderSideBySide,
|
|
194
|
+
readOnly: true,
|
|
195
|
+
automaticLayout: true,
|
|
196
|
+
scrollBeyondLastLine: false,
|
|
197
|
+
}}
|
|
198
|
+
loading={<Loader2 className="size-5 animate-spin text-text-subtle" />}
|
|
199
|
+
/>
|
|
200
|
+
) : (
|
|
201
|
+
<div className="flex items-center justify-center h-full">
|
|
202
|
+
<Loader2 className="size-5 animate-spin text-text-subtle" />
|
|
203
|
+
</div>
|
|
204
|
+
)}
|
|
184
205
|
</div>
|
|
185
206
|
</div>
|
|
186
207
|
);
|
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
import { useFileStore, type FileNode } from "@/stores/file-store";
|
|
15
15
|
import { useProjectStore } from "@/stores/project-store";
|
|
16
16
|
import { useTabStore } from "@/stores/tab-store";
|
|
17
|
-
import { cn } from "@/lib/utils";
|
|
17
|
+
import { cn, basename } from "@/lib/utils";
|
|
18
18
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
19
19
|
import {
|
|
20
20
|
ContextMenu,
|
|
@@ -220,8 +220,8 @@ export function FileTree({ onFileOpen }: FileTreeProps = {}) {
|
|
|
220
220
|
if (action === "compare-selected" && selectedFiles.length === 2) {
|
|
221
221
|
const file1 = selectedFiles[0]!;
|
|
222
222
|
const file2 = selectedFiles[1]!;
|
|
223
|
-
const name1 = file1
|
|
224
|
-
const name2 = file2
|
|
223
|
+
const name1 = basename(file1);
|
|
224
|
+
const name2 = basename(file2);
|
|
225
225
|
openTab({
|
|
226
226
|
type: "git-diff",
|
|
227
227
|
title: `Compare ${name1} vs ${name2}`,
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
GripVertical,
|
|
15
15
|
} from "lucide-react";
|
|
16
16
|
import { api, projectUrl } from "@/lib/api-client";
|
|
17
|
+
import { basename } from "@/lib/utils";
|
|
17
18
|
import { useTabStore } from "@/stores/tab-store";
|
|
18
19
|
import { Button } from "@/components/ui/button";
|
|
19
20
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
@@ -582,7 +583,7 @@ export function GitGraph({ metadata }: GitGraphProps) {
|
|
|
582
583
|
className="flex items-center gap-2 py-0.5 text-xs hover:bg-muted/50 rounded px-1 cursor-pointer"
|
|
583
584
|
onClick={() => openTab({
|
|
584
585
|
type: "git-diff",
|
|
585
|
-
title: `Diff ${file.path
|
|
586
|
+
title: `Diff ${basename(file.path)}`,
|
|
586
587
|
closable: true,
|
|
587
588
|
metadata: {
|
|
588
589
|
projectName,
|