wiki-viewer 1.8.1 → 1.8.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/build-manifest.json +5 -5
  3. package/.next/standalone/.next/server/app/_global-error/page/build-manifest.json +2 -2
  4. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  5. package/.next/standalone/.next/server/app/_global-error.rsc +1 -1
  6. package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  7. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  8. package/.next/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  9. package/.next/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  10. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  11. package/.next/standalone/.next/server/app/_not-found/page/build-manifest.json +2 -2
  12. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  13. package/.next/standalone/.next/server/app/_not-found.html +1 -1
  14. package/.next/standalone/.next/server/app/_not-found.rsc +2 -2
  15. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
  16. package/.next/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  17. package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +2 -2
  18. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  19. package/.next/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  20. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  21. package/.next/standalone/.next/server/app/api/app-proxy/[...path]/route.js.nft.json +1 -1
  22. package/.next/standalone/.next/server/app/api/system/browse/route.js.nft.json +1 -1
  23. package/.next/standalone/.next/server/app/api/wiki/app/route.js.nft.json +1 -1
  24. package/.next/standalone/.next/server/app/api/wiki/upload/route.js.nft.json +1 -1
  25. package/.next/standalone/.next/server/app/index.html +1 -1
  26. package/.next/standalone/.next/server/app/index.rsc +3 -3
  27. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  28. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +3 -3
  29. package/.next/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  30. package/.next/standalone/.next/server/app/index.segments/_index.segment.rsc +2 -2
  31. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  32. package/.next/standalone/.next/server/app/page/build-manifest.json +2 -2
  33. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  34. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  35. package/.next/standalone/.next/server/app/signin/page/build-manifest.json +2 -2
  36. package/.next/standalone/.next/server/app/signin/page_client-reference-manifest.js +1 -1
  37. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0-inabx._.js +1 -1
  38. package/.next/standalone/.next/server/chunks/[root-of-the-server]__00lep-u._.js +1 -1
  39. package/.next/standalone/.next/server/chunks/ssr/{0~w0_katex_dist_katex_mjs_04mhawr._.js → 0~w0_katex_dist_katex_mjs_0hyln.b._.js} +1 -1
  40. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__09~x2v~._.js → [root-of-the-server]__0du58sw._.js} +2 -2
  41. package/.next/standalone/.next/server/chunks/ssr/_0pqaawz._.js +19 -19
  42. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0shlang._.js → node_modules__pnpm_0.kyqfq._.js} +1 -1
  43. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_04n0h6.._.js → node_modules__pnpm_0.~goq~._.js} +1 -1
  44. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_011vy_q._.js → node_modules__pnpm_00y7foi._.js} +2 -2
  45. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0yiz.-t._.js → node_modules__pnpm_03.1jli._.js} +1 -1
  46. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_093dp2q._.js → node_modules__pnpm_03z3na-._.js} +1 -1
  47. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_01o_5qt._.js → node_modules__pnpm_05~w-ok._.js} +1 -1
  48. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0b8qx0g._.js → node_modules__pnpm_09d5yyu._.js} +1 -1
  49. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0wmx9kq._.js → node_modules__pnpm_09eo8jh._.js} +2 -2
  50. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0nw5ou2._.js → node_modules__pnpm_0adm8h6._.js} +1 -1
  51. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0bbc---._.js → node_modules__pnpm_0arbjru._.js} +1 -1
  52. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0t9jb7d._.js → node_modules__pnpm_0b5fla7._.js} +1 -1
  53. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_124u4o2._.js → node_modules__pnpm_0beormu._.js} +2 -2
  54. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_13tajgx._.js → node_modules__pnpm_0c7u76a._.js} +2 -2
  55. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_09ej62_._.js → node_modules__pnpm_0dbr7c2._.js} +1 -1
  56. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0s0ur~d._.js → node_modules__pnpm_0d~ida~._.js} +1 -1
  57. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_09ljbdz._.js → node_modules__pnpm_0elpfsb._.js} +2 -2
  58. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0hlzy8f._.js → node_modules__pnpm_0fh.6ir._.js} +1 -1
  59. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_09~_ob2._.js → node_modules__pnpm_0fm6wcx._.js} +1 -1
  60. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0o.dn8o._.js → node_modules__pnpm_0iikbcy._.js} +1 -1
  61. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0hk6xys._.js → node_modules__pnpm_0mvih7b._.js} +2 -2
  62. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0a01ng7._.js → node_modules__pnpm_0qd2ovj._.js} +2 -2
  63. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0c.309.._.js → node_modules__pnpm_0s4yn43._.js} +1 -1
  64. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0sq3c2r._.js → node_modules__pnpm_0s8fvbn._.js} +1 -1
  65. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0xfx0sf._.js → node_modules__pnpm_0tq8zs6._.js} +1 -1
  66. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_11sut1u._.js → node_modules__pnpm_0vyv.lz._.js} +1 -1
  67. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0c8aunj._.js → node_modules__pnpm_0xa.ko1._.js} +1 -1
  68. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0.96tmo._.js → node_modules__pnpm_12.ymhl._.js} +1 -1
  69. package/.next/standalone/.next/server/middleware-build-manifest.js +5 -5
  70. package/.next/standalone/.next/server/middleware-manifest.json +1 -1
  71. package/.next/standalone/.next/server/pages/404.html +1 -1
  72. package/.next/standalone/.next/server/pages/500.html +1 -1
  73. package/.next/standalone/.next/static/chunks/{0vp-ahhagxptc.js → 06iw_iy5e90d4.js} +1 -1
  74. package/.next/standalone/.next/static/chunks/0d~o0h0ssgra7.js +129 -0
  75. package/.next/standalone/.next/static/chunks/167ph4xws_-61.css +2 -0
  76. package/.next/standalone/.next/static/chunks/{turbopack-12~z-mr7b9b50.js → turbopack-0lf4~b-ofzbm6.js} +1 -1
  77. package/.next/standalone/AGENTS.md +104 -0
  78. package/.next/standalone/package.json +1 -1
  79. package/.next/standalone/packages/wiki-viewer-mcp/package-lock.json +2 -2
  80. package/.next/standalone/packages/wiki-viewer-mcp/package.json +1 -1
  81. package/.next/standalone/src/app/api/wiki/app/route.ts +7 -4
  82. package/.next/standalone/src/app/page.tsx +631 -193
  83. package/.next/standalone/src/components/editor/backlinks-panel.tsx +152 -0
  84. package/.next/standalone/src/components/editor/document-outline.tsx +221 -0
  85. package/.next/standalone/src/components/editor/editor.tsx +145 -48
  86. package/.next/standalone/src/components/editor/node-app-viewer.tsx +65 -7
  87. package/.next/standalone/src/components/editor/slash-commands.tsx +216 -41
  88. package/.next/standalone/src/components/editor/source-viewer.tsx +29 -2
  89. package/.next/standalone/src/components/search/search-command-dialog.tsx +183 -46
  90. package/.next/standalone/src/lib/app-runner.ts +57 -16
  91. package/.next/standalone/src/stores/pin-store.ts +55 -0
  92. package/.next/standalone/src/stores/recent-store.ts +50 -0
  93. package/.next/standalone/tsconfig.tsbuildinfo +1 -1
  94. package/package.json +1 -1
  95. package/.next/standalone/.next/static/chunks/0c_~zevf2n0r4.css +0 -2
  96. package/.next/standalone/.next/static/chunks/0l4842ez-0_66.js +0 -129
  97. package/.next/standalone/src/components/wiki/markdown-preview.tsx +0 -120
  98. /package/.next/standalone/.next/static/{17VWCQ9_AhwJBT4yjkvWG → Pkj31aCfRVk0Zy2rKA1wf}/_buildManifest.js +0 -0
  99. /package/.next/standalone/.next/static/{17VWCQ9_AhwJBT4yjkvWG → Pkj31aCfRVk0Zy2rKA1wf}/_clientMiddlewareManifest.js +0 -0
  100. /package/.next/standalone/.next/static/{17VWCQ9_AhwJBT4yjkvWG → Pkj31aCfRVk0Zy2rKA1wf}/_ssgManifest.js +0 -0
@@ -213,6 +213,155 @@ const commands: SlashCommand[] = [
213
213
  },
214
214
  ];
215
215
 
216
+ /** Small static preview rendered at the bottom of the menu for the focused command. */
217
+ function CommandPreview({ cmd }: { cmd: SlashCommand }) {
218
+ switch (cmd.label) {
219
+ case "Heading 1":
220
+ return (
221
+ <p className="font-bold text-[18px] leading-tight text-foreground/80 truncate">
222
+ Heading 1
223
+ </p>
224
+ );
225
+ case "Heading 2":
226
+ return (
227
+ <p className="font-semibold text-[14px] leading-tight text-foreground/80 truncate">
228
+ Heading 2
229
+ </p>
230
+ );
231
+ case "Heading 3":
232
+ return (
233
+ <p className="font-medium text-[12px] leading-tight text-foreground/80 truncate">
234
+ Heading 3
235
+ </p>
236
+ );
237
+ case "Text":
238
+ return (
239
+ <p className="text-[11px] text-foreground/60 leading-snug">
240
+ The quick brown fox jumps over the lazy dog.
241
+ </p>
242
+ );
243
+ case "Bullet List":
244
+ return (
245
+ <ul className="list-disc pl-4 text-[10px] text-foreground/60 space-y-0.5">
246
+ <li>First item</li>
247
+ <li>Second item</li>
248
+ <li>Third item</li>
249
+ </ul>
250
+ );
251
+ case "Numbered List":
252
+ return (
253
+ <ol className="list-decimal pl-4 text-[10px] text-foreground/60 space-y-0.5">
254
+ <li>First item</li>
255
+ <li>Second item</li>
256
+ <li>Third item</li>
257
+ </ol>
258
+ );
259
+ case "Checklist":
260
+ return (
261
+ <div className="space-y-0.5 text-[10px] text-foreground/60">
262
+ <div className="flex items-center gap-1.5">
263
+ <span className="w-3 h-3 border border-foreground/30 rounded-sm inline-block shrink-0" />
264
+ Pending task
265
+ </div>
266
+ <div className="flex items-center gap-1.5">
267
+ <span className="w-3 h-3 border border-foreground/30 rounded-sm bg-foreground/20 inline-block shrink-0" />
268
+ Done task
269
+ </div>
270
+ </div>
271
+ );
272
+ case "Code Block":
273
+ return (
274
+ <pre className="bg-muted rounded px-2 py-1 text-[10px] font-mono text-foreground/70 leading-snug">
275
+ <code>{"const x = 42;"}</code>
276
+ </pre>
277
+ );
278
+ case "Blockquote":
279
+ return (
280
+ <div className="border-l-2 border-foreground/30 pl-2 text-[10px] text-foreground/60 italic">
281
+ A thought worth quoting.
282
+ </div>
283
+ );
284
+ case "Divider":
285
+ return <hr className="border-foreground/20 w-full" />;
286
+ case "Table":
287
+ return (
288
+ <table className="text-[9px] border-collapse w-full">
289
+ <thead>
290
+ <tr>
291
+ {["Col A", "Col B", "Col C"].map((h) => (
292
+ <th
293
+ key={h}
294
+ className="border border-foreground/20 px-1.5 py-0.5 text-left font-semibold bg-muted/50 text-foreground/70"
295
+ >
296
+ {h}
297
+ </th>
298
+ ))}
299
+ </tr>
300
+ </thead>
301
+ <tbody>
302
+ {[["1", "2", "3"], ["4", "5", "6"]].map((row, i) => (
303
+ <tr key={i}>
304
+ {row.map((cell, j) => (
305
+ <td
306
+ key={j}
307
+ className="border border-foreground/20 px-1.5 py-0.5 text-foreground/50"
308
+ >
309
+ {cell}
310
+ </td>
311
+ ))}
312
+ </tr>
313
+ ))}
314
+ </tbody>
315
+ </table>
316
+ );
317
+ case "Image":
318
+ return (
319
+ <div className="flex items-center gap-2 text-foreground/50">
320
+ <ImageIcon className="h-8 w-8 text-foreground/20" />
321
+ <span className="text-[10px]">image.png</span>
322
+ </div>
323
+ );
324
+ case "Video":
325
+ return (
326
+ <div className="flex items-center gap-2 text-foreground/50">
327
+ <Video className="h-8 w-8 text-foreground/20" />
328
+ <span className="text-[10px]">video.mp4</span>
329
+ </div>
330
+ );
331
+ case "File":
332
+ return (
333
+ <div className="flex items-center gap-2 text-foreground/50">
334
+ <File className="h-8 w-8 text-foreground/20" />
335
+ <span className="text-[10px]">attachment.pdf</span>
336
+ </div>
337
+ );
338
+ case "Callout":
339
+ return (
340
+ <div className="flex items-start gap-1.5 bg-blue-500/10 border border-blue-500/20 rounded px-2 py-1">
341
+ <Info className="h-3 w-3 text-blue-500 shrink-0 mt-0.5" />
342
+ <span className="text-[10px] text-foreground/60">Info callout</span>
343
+ </div>
344
+ );
345
+ case "Warning":
346
+ return (
347
+ <div className="flex items-start gap-1.5 bg-yellow-500/10 border border-yellow-500/20 rounded px-2 py-1">
348
+ <AlertTriangle className="h-3 w-3 text-yellow-500 shrink-0 mt-0.5" />
349
+ <span className="text-[10px] text-foreground/60">Warning callout</span>
350
+ </div>
351
+ );
352
+ case "Math":
353
+ return (
354
+ <p className="font-mono text-[13px] text-foreground/70">
355
+ E = mc²
356
+ </p>
357
+ );
358
+ default:
359
+ return (
360
+ <p className="text-[10px] text-muted-foreground">{cmd.description}</p>
361
+ );
362
+ }
363
+ }
364
+
216
365
  interface SlashCommandsProps {
217
366
  editor: Editor | null;
218
367
  }
@@ -396,56 +545,82 @@ export function SlashCommands({ editor }: SlashCommandsProps) {
396
545
  ];
397
546
 
398
547
  const flatCommands: SlashCommand[] = filtered;
548
+ const focusedCmd = flatCommands[selectedIndex] ?? null;
399
549
 
400
550
  return (
401
551
  <>
402
552
  {open && filtered.length > 0 && (
403
553
  <div
404
554
  ref={menuRef}
405
- className="absolute z-50 w-[280px] bg-popover border border-border rounded-lg shadow-lg py-1 overflow-hidden max-h-[380px] overflow-y-auto"
555
+ className="absolute z-50 w-[280px] bg-popover border border-border rounded-lg shadow-lg overflow-hidden"
406
556
  style={{ top: position.top, left: position.left }}
407
557
  >
408
- {order.map((group) => {
409
- const items = byCategory.get(group.key);
410
- if (!items || items.length === 0) return null;
411
- return (
412
- <div key={group.key}>
413
- <div className="text-[9px] uppercase tracking-wider text-muted-foreground px-3 pt-2 pb-1">
414
- {group.title}
558
+ {/* Scrollable command list */}
559
+ <div className="max-h-[300px] overflow-y-auto py-1">
560
+ {order.map((group) => {
561
+ const items = byCategory.get(group.key);
562
+ if (!items || items.length === 0) return null;
563
+ return (
564
+ <div key={group.key}>
565
+ <div className="text-[9px] uppercase tracking-wider text-muted-foreground px-3 pt-2 pb-1">
566
+ {group.title}
567
+ </div>
568
+ {items.map((cmd) => {
569
+ const flatIndex = flatCommands.indexOf(cmd);
570
+ const Icon = cmd.icon;
571
+ const isFocused = flatIndex === selectedIndex;
572
+ return (
573
+ <button
574
+ key={cmd.label}
575
+ onMouseDown={(e) => {
576
+ e.preventDefault();
577
+ handleSelect(cmd);
578
+ }}
579
+ onMouseEnter={() => setSelectedIndex(flatIndex)}
580
+ className={cn(
581
+ "flex items-center gap-3 w-full px-3 py-1.5 text-left transition-colors",
582
+ isFocused
583
+ ? "bg-accent text-accent-foreground ring-1 ring-inset ring-accent-foreground/10"
584
+ : "hover:bg-accent/50",
585
+ )}
586
+ >
587
+ <Icon
588
+ className={cn(
589
+ "h-4 w-4 shrink-0 transition-colors",
590
+ isFocused
591
+ ? "text-accent-foreground"
592
+ : "text-muted-foreground",
593
+ )}
594
+ />
595
+ <div className="min-w-0">
596
+ <p className="text-[12px] font-medium truncate">
597
+ {cmd.label}
598
+ </p>
599
+ <p
600
+ className={cn(
601
+ "text-[10px] truncate transition-colors",
602
+ isFocused
603
+ ? "text-accent-foreground/70"
604
+ : "text-muted-foreground",
605
+ )}
606
+ >
607
+ {cmd.description}
608
+ </p>
609
+ </div>
610
+ </button>
611
+ );
612
+ })}
415
613
  </div>
416
- {items.map((cmd) => {
417
- const flatIndex = flatCommands.indexOf(cmd);
418
- const Icon = cmd.icon;
419
- return (
420
- <button
421
- key={cmd.label}
422
- onMouseDown={(e) => {
423
- e.preventDefault();
424
- handleSelect(cmd);
425
- }}
426
- onMouseEnter={() => setSelectedIndex(flatIndex)}
427
- className={cn(
428
- "flex items-center gap-3 w-full px-3 py-1.5 text-left transition-colors",
429
- flatIndex === selectedIndex
430
- ? "bg-accent text-accent-foreground"
431
- : "hover:bg-accent/50",
432
- )}
433
- >
434
- <Icon className="h-4 w-4 text-muted-foreground shrink-0" />
435
- <div className="min-w-0">
436
- <p className="text-[12px] font-medium truncate">
437
- {cmd.label}
438
- </p>
439
- <p className="text-[10px] text-muted-foreground truncate">
440
- {cmd.description}
441
- </p>
442
- </div>
443
- </button>
444
- );
445
- })}
446
- </div>
447
- );
448
- })}
614
+ );
615
+ })}
616
+ </div>
617
+
618
+ {/* Preview pane for focused command */}
619
+ {focusedCmd && (
620
+ <div className="border-t border-border bg-muted/30 px-3 py-2 min-h-[52px] flex items-center">
621
+ <CommandPreview cmd={focusedCmd} />
622
+ </div>
623
+ )}
449
624
  </div>
450
625
  )}
451
626
  {renderPopover()}
@@ -7,6 +7,23 @@ import { useCallback, useEffect, useMemo, useState } from "react";
7
7
  import { ViewerToolbar } from "@/components/layout/viewer-toolbar";
8
8
  import { Button } from "@/components/ui/button";
9
9
  import { withWs, wsFetch } from "@/lib/workspace-client";
10
+ import { FileFallbackViewer } from "@/components/editor/file-fallback-viewer";
11
+
12
+ // Heuristic binary sniff: a NUL byte never appears in UTF-8/UTF-16LE text we
13
+ // care about, and a high ratio of control chars (excluding tab/newline/CR)
14
+ // signals binary. Only inspect a prefix \u2014 enough to classify cheaply.
15
+ function looksBinary(bytes: Uint8Array): boolean {
16
+ const n = Math.min(bytes.length, 8192);
17
+ if (n === 0) return false;
18
+ let suspicious = 0;
19
+ for (let i = 0; i < n; i++) {
20
+ const b = bytes[i];
21
+ if (b === 0) return true; // NUL \u2192 definitely binary
22
+ // Allow tab(9), LF(10), CR(13); flag other C0 control chars.
23
+ if (b < 9 || (b > 13 && b < 32)) suspicious++;
24
+ }
25
+ return suspicious / n > 0.3;
26
+ }
10
27
 
11
28
  interface SourceViewerProps {
12
29
  path: string;
@@ -73,6 +90,7 @@ function formatBadge(filename: string): string {
73
90
 
74
91
  export function SourceViewer({ path }: SourceViewerProps) {
75
92
  const [content, setContent] = useState<string | null>(null);
93
+ const [binary, setBinary] = useState(false);
76
94
  const [loading, setLoading] = useState(true);
77
95
  const [wrap, setWrap] = useState(false);
78
96
  const [copied, setCopied] = useState(false);
@@ -86,8 +104,12 @@ export function SourceViewer({ path }: SourceViewerProps) {
86
104
  try {
87
105
  const res = await wsFetch(assetUrl);
88
106
  if (res.ok) {
89
- const text = await res.text();
90
- setContent(text);
107
+ const bytes = new Uint8Array(await res.arrayBuffer());
108
+ if (looksBinary(bytes)) {
109
+ setBinary(true);
110
+ } else {
111
+ setContent(new TextDecoder("utf-8").decode(bytes));
112
+ }
91
113
  }
92
114
  } catch {
93
115
  /* ignore */
@@ -129,6 +151,11 @@ export function SourceViewer({ path }: SourceViewerProps) {
129
151
  setTimeout(() => setCopied(false), 2000);
130
152
  };
131
153
 
154
+ // Detected binary at runtime \u2192 reuse the download/reveal fallback UI.
155
+ if (binary) {
156
+ return <FileFallbackViewer path={path} title={filename} />;
157
+ }
158
+
132
159
  return (
133
160
  <div className="flex-1 flex flex-col overflow-hidden">
134
161
  <ViewerToolbar
@@ -1,10 +1,24 @@
1
1
  "use client";
2
2
 
3
3
  /**
4
- * Command palette (cmd+k / ctrl+k) for full-text search.
4
+ * Command palette (cmd+k / ctrl+k) for full-text search + app actions.
5
5
  * Mounts once in the app shell; visibility is driven by the search store.
6
+ *
7
+ * Action mode activates when the query is empty or starts with ">".
8
+ * New optional props let the parent wire page-level callbacks; they default
9
+ * to no-ops so this component compiles without any parent changes.
6
10
  */
7
11
  import { useEffect } from "react";
12
+ import {
13
+ AlignCenter,
14
+ Bot,
15
+ Copy,
16
+ FilePlus,
17
+ Moon,
18
+ PanelLeft,
19
+ Sun,
20
+ } from "lucide-react";
21
+ import { useTheme } from "next-themes";
8
22
  import {
9
23
  CommandDialog,
10
24
  CommandEmpty,
@@ -12,15 +26,33 @@ import {
12
26
  CommandInput,
13
27
  CommandItem,
14
28
  CommandList,
29
+ CommandSeparator,
15
30
  } from "@/components/ui/command";
16
31
  import { useSearchStore } from "@/stores/search-store";
32
+ import { useAIPanelStore } from "@/stores/ai-panel-store";
33
+ import {
34
+ useViewWidthStore,
35
+ VIEW_WIDTH_LABEL,
36
+ VIEW_WIDTH_ORDER,
37
+ } from "@/stores/view-width-store";
17
38
  import { SnippetText } from "./snippet-text";
18
39
 
40
+ export interface SearchCommandDialogProps {
41
+ onOpenFile: (relPath: string) => void;
42
+ /** Wire to page-level sidebar toggle. Defaults to no-op. */
43
+ onToggleSidebar?: () => void;
44
+ /** Wire to page-level new-file action. Defaults to no-op. */
45
+ onNewFile?: () => void;
46
+ /** Wire to page-level copy-path action. Defaults to no-op. */
47
+ onCopyPath?: () => void;
48
+ }
49
+
19
50
  export function SearchCommandDialog({
20
51
  onOpenFile,
21
- }: {
22
- onOpenFile: (relPath: string) => void;
23
- }) {
52
+ onToggleSidebar = () => undefined,
53
+ onNewFile = () => undefined,
54
+ onCopyPath = () => undefined,
55
+ }: SearchCommandDialogProps) {
24
56
  const open = useSearchStore((s) => s.open);
25
57
  const setOpen = useSearchStore((s) => s.setOpen);
26
58
  const query = useSearchStore((s) => s.query);
@@ -30,6 +62,18 @@ export function SearchCommandDialog({
30
62
  const truncated = useSearchStore((s) => s.truncated);
31
63
  const search = useSearchStore((s) => s.search);
32
64
 
65
+ const { resolvedTheme, setTheme } = useTheme();
66
+ const toggleAIPanel = useAIPanelStore((s) => s.toggle);
67
+ const viewWidth = useViewWidthStore((s) => s.width);
68
+ const cycleViewWidth = useViewWidthStore((s) => s.cycle);
69
+
70
+ // Action mode: empty query or ">" prefix (VS Code convention).
71
+ const trimmed = query.trimStart();
72
+ const isActionMode = trimmed === "" || trimmed.startsWith(">");
73
+
74
+ // The bare text to actually search files (strip leading ">").
75
+ const fileQuery = isActionMode ? "" : query;
76
+
33
77
  // Global cmd+k / ctrl+k toggle.
34
78
  useEffect(() => {
35
79
  function onKey(e: KeyboardEvent) {
@@ -42,67 +86,160 @@ export function SearchCommandDialog({
42
86
  return () => window.removeEventListener("keydown", onKey);
43
87
  }, [open, setOpen]);
44
88
 
45
- // Debounced search as the query changes while the palette is open.
89
+ // Debounced search only fires when NOT in action mode.
46
90
  useEffect(() => {
47
91
  if (!open) return;
92
+ if (isActionMode) return;
48
93
  const id = setTimeout(() => {
49
- void search(query);
94
+ void search(fileQuery);
50
95
  }, 120);
51
96
  return () => clearTimeout(id);
52
- }, [query, open, search]);
97
+ }, [fileQuery, isActionMode, open, search]);
98
+
99
+ // Helper: run action then close.
100
+ function runAction(fn: () => void) {
101
+ fn();
102
+ setOpen(false);
103
+ setQuery("");
104
+ }
105
+
106
+ // Next view-width label for display.
107
+ const nextWidthIdx =
108
+ (VIEW_WIDTH_ORDER.indexOf(viewWidth) + 1) % VIEW_WIDTH_ORDER.length;
109
+ const nextWidthLabel = VIEW_WIDTH_LABEL[VIEW_WIDTH_ORDER[nextWidthIdx]];
53
110
 
54
111
  return (
55
112
  <CommandDialog
56
113
  open={open}
57
- onOpenChange={setOpen}
114
+ onOpenChange={(v) => {
115
+ setOpen(v);
116
+ if (!v) setQuery("");
117
+ }}
58
118
  // cmdk's built-in fuzzy filter would re-rank our BM25 results; disable it.
59
119
  shouldFilter={false}
60
120
  >
61
121
  <CommandInput
62
- placeholder="Search files…"
122
+ placeholder="Search files… ( > for actions )"
63
123
  value={query}
64
124
  onValueChange={setQuery}
65
125
  />
66
126
  <CommandList>
67
- {loading && (
68
- <div className="px-3 py-2 text-xs text-muted-foreground">
69
- Searching…
70
- </div>
71
- )}
72
- {!loading && query.trim() !== "" && results.length === 0 && (
73
- <CommandEmpty>No matches.</CommandEmpty>
74
- )}
75
- {results.length > 0 && (
76
- <CommandGroup
77
- heading={
78
- truncated
79
- ? "Top results (refine query for more)"
80
- : "Results"
81
- }
82
- >
83
- {results.map((r) => (
84
- <CommandItem
85
- key={r.path}
86
- value={r.path}
87
- onSelect={() => {
88
- onOpenFile(r.path);
89
- setOpen(false);
90
- }}
91
- >
92
- <div className="flex min-w-0 flex-col gap-0.5">
93
- <span className="truncate font-mono text-xs">
94
- {r.path}
95
- </span>
96
- {r.snippet && (
97
- <span className="truncate text-xs text-muted-foreground">
98
- <SnippetText html={r.snippet} />
99
- </span>
100
- )}
101
- </div>
102
- </CommandItem>
103
- ))}
127
+ {/* ── Action mode ─────────────────────────────────────── */}
128
+ {isActionMode && (
129
+ <CommandGroup heading="Actions">
130
+ <CommandItem
131
+ value="toggle-dark-mode"
132
+ onSelect={() =>
133
+ runAction(() =>
134
+ setTheme(resolvedTheme === "dark" ? "light" : "dark"),
135
+ )
136
+ }
137
+ >
138
+ {resolvedTheme === "dark" ? (
139
+ <Sun className="mr-2 h-4 w-4" />
140
+ ) : (
141
+ <Moon className="mr-2 h-4 w-4" />
142
+ )}
143
+ {resolvedTheme === "dark"
144
+ ? "Switch to light mode"
145
+ : "Switch to dark mode"}
146
+ </CommandItem>
147
+
148
+ <CommandItem
149
+ value="toggle-ai-panel"
150
+ onSelect={() => runAction(toggleAIPanel)}
151
+ >
152
+ <Bot className="mr-2 h-4 w-4" />
153
+ Toggle AI panel
154
+ </CommandItem>
155
+
156
+ <CommandItem
157
+ value="toggle-sidebar"
158
+ onSelect={() => runAction(onToggleSidebar)}
159
+ >
160
+ <PanelLeft className="mr-2 h-4 w-4" />
161
+ Toggle sidebar
162
+ </CommandItem>
163
+
164
+ <CommandItem
165
+ value="new-file"
166
+ onSelect={() => runAction(onNewFile)}
167
+ >
168
+ <FilePlus className="mr-2 h-4 w-4" />
169
+ New file
170
+ </CommandItem>
171
+
172
+ <CommandItem
173
+ value="copy-path"
174
+ onSelect={() => runAction(onCopyPath)}
175
+ >
176
+ <Copy className="mr-2 h-4 w-4" />
177
+ Copy current file path
178
+ </CommandItem>
179
+
180
+ <CommandItem
181
+ value="cycle-view-width"
182
+ onSelect={() => runAction(cycleViewWidth)}
183
+ >
184
+ <AlignCenter className="mr-2 h-4 w-4" />
185
+ <span>
186
+ Change view width{" "}
187
+ <span className="text-muted-foreground">
188
+ ({VIEW_WIDTH_LABEL[viewWidth]} → {nextWidthLabel})
189
+ </span>
190
+ </span>
191
+ </CommandItem>
104
192
  </CommandGroup>
105
193
  )}
194
+
195
+ {/* ── File-search mode ────────────────────────────────── */}
196
+ {!isActionMode && (
197
+ <>
198
+ {loading && (
199
+ <div className="px-3 py-2 text-xs text-muted-foreground">
200
+ Searching…
201
+ </div>
202
+ )}
203
+ {!loading && fileQuery.trim() !== "" && results.length === 0 && (
204
+ <CommandEmpty>No matches.</CommandEmpty>
205
+ )}
206
+ {results.length > 0 && (
207
+ <>
208
+ <CommandSeparator />
209
+ <CommandGroup
210
+ heading={
211
+ truncated
212
+ ? "Top results (refine query for more)"
213
+ : "Results"
214
+ }
215
+ >
216
+ {results.map((r) => (
217
+ <CommandItem
218
+ key={r.path}
219
+ value={r.path}
220
+ onSelect={() => {
221
+ onOpenFile(r.path);
222
+ setOpen(false);
223
+ setQuery("");
224
+ }}
225
+ >
226
+ <div className="flex min-w-0 flex-col gap-0.5">
227
+ <span className="truncate font-mono text-xs">
228
+ {r.path}
229
+ </span>
230
+ {r.snippet && (
231
+ <span className="truncate text-xs text-muted-foreground">
232
+ <SnippetText html={r.snippet} />
233
+ </span>
234
+ )}
235
+ </div>
236
+ </CommandItem>
237
+ ))}
238
+ </CommandGroup>
239
+ </>
240
+ )}
241
+ </>
242
+ )}
106
243
  </CommandList>
107
244
  </CommandDialog>
108
245
  );