ompchamber 3.0.0 → 3.1.0

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/README.md +24 -5
  2. package/dist/client/index.html +1 -1
  3. package/dist/client/index.js +333 -333
  4. package/dist/client/static/js/{index-prbmx3yj.js → index-8t70jhyj.js} +643 -617
  5. package/dist/client/static/js/index-n4wkqj7t.css +1 -0
  6. package/package.json +1 -1
  7. package/src/client/components/common/code-editor/handler.test.ts +274 -0
  8. package/src/client/components/common/code-editor/handler.ts +175 -0
  9. package/src/client/components/common/code-editor/index.tsx +185 -220
  10. package/src/client/components/common/code-surface/index.tsx +109 -12
  11. package/src/client/components/common/lazy-panels.tsx +5 -1
  12. package/src/client/components/common/viewport.ts +5 -3
  13. package/src/client/components/layout/RightActivityBar.tsx +2 -1
  14. package/src/client/components/layout/desktop-layout/TopNavbar.tsx +21 -4
  15. package/src/client/components/layout/desktop-layout/index.tsx +24 -4
  16. package/src/client/components/mobile/RightSidebar.tsx +11 -4
  17. package/src/client/components/workspace/browser-panel/AddressBar.tsx +1 -10
  18. package/src/client/components/workspace/browser-panel/Screencast.tsx +4 -5
  19. package/src/client/components/workspace/browser-panel/index.tsx +6 -6
  20. package/src/client/components/workspace/browser-toolbar/index.tsx +22 -12
  21. package/src/client/components/workspace/chat-timeline/ToolCallCard.tsx +1 -1
  22. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Todo.tsx +1 -1
  23. package/src/client/components/workspace/editor/CommandPalette.test.ts +242 -0
  24. package/src/client/components/workspace/editor/CommandPalette.tsx +138 -0
  25. package/src/client/components/workspace/editor/FindWidget.test.ts +261 -0
  26. package/src/client/components/workspace/editor/FindWidget.tsx +219 -0
  27. package/src/client/components/workspace/editor/Toolbar.tsx +19 -1
  28. package/src/client/components/workspace/editor/index.tsx +153 -53
  29. package/src/client/components/workspace/file-explorer/Modals.tsx +79 -2
  30. package/src/client/components/workspace/file-explorer/TreeItem.tsx +43 -113
  31. package/src/client/components/workspace/todo-panel/PhaseGroup.tsx +76 -0
  32. package/src/client/components/workspace/todo-panel/Row.tsx +69 -0
  33. package/src/client/components/workspace/todo-panel/index.tsx +161 -0
  34. package/src/client/hooks/editor/find-harness.test-util.ts +170 -0
  35. package/src/client/hooks/editor/scroll-container.test.ts +102 -0
  36. package/src/client/hooks/editor/scroll-container.ts +26 -0
  37. package/src/client/hooks/editor/use-code-window.ts +2 -20
  38. package/src/client/hooks/editor/use-editor-find-write.test.ts +170 -0
  39. package/src/client/hooks/editor/use-editor-find.test.ts +118 -0
  40. package/src/client/hooks/editor/use-editor-find.ts +321 -0
  41. package/src/client/hooks/editor/use-editor-history.test.ts +216 -0
  42. package/src/client/hooks/editor/use-editor-history.ts +134 -0
  43. package/src/client/hooks/editor/use-occurrences.test.ts +236 -0
  44. package/src/client/hooks/editor/use-occurrences.ts +98 -0
  45. package/src/client/hooks/workspace/file-tree-actions.test.ts +348 -0
  46. package/src/client/hooks/workspace/file-tree-actions.ts +250 -0
  47. package/src/client/hooks/workspace/session-todos.ts +102 -0
  48. package/src/client/tailwind.css +30 -0
  49. package/src/server/lib/omp/session/todos.ts +100 -0
  50. package/src/server/routes/fs/action-create.test.ts +90 -0
  51. package/src/server/routes/fs/action.ts +23 -0
  52. package/src/server/routes/omp/index.ts +2 -0
  53. package/src/server/routes/omp/session-todos.ts +80 -0
  54. package/src/shared/lib/chat/{todo-parser.ts → todo/parser.ts} +17 -0
  55. package/src/shared/lib/chat/todo/progress.ts +97 -0
  56. package/src/shared/lib/chat/todo/snapshot.test.ts +156 -0
  57. package/src/shared/lib/chat/todo/snapshot.ts +215 -0
  58. package/src/shared/lib/code/editor/commands.test.ts +155 -0
  59. package/src/shared/lib/code/editor/commands.ts +260 -0
  60. package/src/shared/lib/code/editor/edits.test.ts +100 -0
  61. package/src/shared/lib/code/editor/edits.ts +122 -0
  62. package/src/shared/lib/code/editor/find-status.test.ts +50 -0
  63. package/src/shared/lib/code/editor/find-status.ts +36 -0
  64. package/src/shared/lib/code/editor/find.test.ts +203 -0
  65. package/src/shared/lib/code/editor/find.ts +224 -0
  66. package/src/{client/components/common/code-editor → shared/lib/code/editor}/history.test.ts +1 -1
  67. package/src/shared/lib/code/editor/keymap.test.ts +109 -0
  68. package/src/shared/lib/code/editor/keymap.ts +151 -0
  69. package/src/shared/lib/code/editor/layers.test.ts +77 -0
  70. package/src/shared/lib/code/editor/layers.ts +67 -0
  71. package/src/shared/lib/code/editor/line-edits.test.ts +131 -0
  72. package/src/shared/lib/code/editor/line-edits.ts +159 -0
  73. package/src/shared/lib/code/editor/lines.test.ts +81 -0
  74. package/src/shared/lib/code/editor/lines.ts +83 -0
  75. package/src/shared/lib/code/editor/marks.test.ts +86 -0
  76. package/src/shared/lib/code/editor/marks.ts +106 -0
  77. package/src/shared/lib/code/editor/occurrences.test.ts +117 -0
  78. package/src/shared/lib/code/editor/occurrences.ts +111 -0
  79. package/src/shared/lib/code/editor/palette.test.ts +91 -0
  80. package/src/shared/lib/code/editor/palette.ts +92 -0
  81. package/src/shared/lib/code/editor/reset-css.test.ts +35 -0
  82. package/src/shared/lib/code/editor/reset-css.ts +32 -0
  83. package/src/shared/lib/code/editor/text-commands.test.ts +178 -0
  84. package/src/shared/lib/code/editor/text-commands.ts +154 -0
  85. package/src/shared/lib/code/syntax-highlight.test.ts +75 -0
  86. package/src/shared/lib/code/syntax-highlight.ts +29 -17
  87. package/src/shared/lib/code/windowed-highlight.ts +52 -1
  88. package/src/shared/lib/ui/key-binding.test.ts +115 -0
  89. package/src/shared/lib/ui/key-binding.ts +143 -0
  90. package/src/shared/lib/util/platform.test.ts +87 -0
  91. package/src/shared/lib/util/platform.ts +30 -0
  92. package/src/shared/lib/workspace/keymap.test.ts +82 -0
  93. package/src/shared/lib/workspace/keymap.ts +30 -0
  94. package/src/shared/lib/workspace/refresh-cadence.ts +9 -0
  95. package/src/shared/lib/workspace/right-panels.ts +8 -0
  96. package/src/shared/lib/workspace/session-state/store.ts +0 -1
  97. package/src/shared/types/index.ts +1 -0
  98. package/src/shared/types/todo.ts +108 -0
  99. package/dist/client/static/js/index-ranztes9.css +0 -1
  100. /package/src/{client/components/common/code-editor → shared/lib/code/editor}/history.ts +0 -0
@@ -0,0 +1,219 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ import { useEffect, useRef } from 'preact/hooks';
7
+ import type { ComponentChildren, TargetedKeyboardEvent } from 'preact';
8
+ import {
9
+ ArrowDown,
10
+ ArrowUp,
11
+ CaseSensitive,
12
+ ChevronDown,
13
+ ChevronUp,
14
+ Regex,
15
+ Replace,
16
+ ReplaceAll,
17
+ WholeWord,
18
+ X,
19
+ } from 'lucide-preact';
20
+
21
+ import { EDITOR_KEY_BINDINGS, type EditorCommand } from '@/shared/lib/code/editor/keymap';
22
+ import { describeBinding } from '@/shared/lib/ui/key-binding';
23
+ import { isMacPlatform } from '@/shared/lib/util/platform';
24
+ import type { EditorFindState } from '@/client/hooks/editor/use-editor-find';
25
+ import type { FindOptions } from '@/shared/lib/code/editor/find';
26
+
27
+ const BUTTON_CLASS =
28
+ 'flex items-center justify-center p-1 rounded transition-colors cursor-pointer disabled:cursor-default disabled:opacity-40';
29
+
30
+ interface FindWidgetProps {
31
+ find: EditorFindState;
32
+ }
33
+
34
+ /** One of the three toggle buttons: `aria-pressed` is the state, the title the shortcut. */
35
+ function ToggleButton({
36
+ active,
37
+ title,
38
+ onToggle,
39
+ children,
40
+ }: {
41
+ active: boolean;
42
+ title: string;
43
+ onToggle: () => void;
44
+ children: ComponentChildren;
45
+ }) {
46
+ return (
47
+ <button
48
+ type="button"
49
+ onClick={onToggle}
50
+ className={`${BUTTON_CLASS} ${active ? 'bg-ink/10 text-ink' : 'text-ink/50 hover:text-ink hover:bg-ink/5'}`}
51
+ title={title}
52
+ aria-label={title}
53
+ aria-pressed={active}
54
+ >
55
+ {children}
56
+ </button>
57
+ );
58
+ }
59
+
60
+ /**
61
+ * The find/replace bar, pinned to the editor's top-right corner the way VS
62
+ * Code pins it.
63
+ *
64
+ * Two rows, not one: the query and its toggles, then the replacement and the
65
+ * two write actions. A single row would have to hide either the toggles or the
66
+ * replace buttons, and both are things this bar exists to expose.
67
+ *
68
+ * The counts are the honest ones — `N of M`, `M+` past the match cap, and a
69
+ * distinct message for a query that does not compile, because "no results" and
70
+ * "your regex is broken" are different answers and the user has to be able to
71
+ * tell them apart.
72
+ */
73
+ export function FindWidget({ find }: FindWidgetProps) {
74
+ const queryRef = useRef<HTMLInputElement | null>(null);
75
+ const isMac = isMacPlatform();
76
+
77
+ useEffect(() => {
78
+ queryRef.current?.focus();
79
+ queryRef.current?.select();
80
+ }, [find.focusRequest]);
81
+
82
+ const total = find.matches.length;
83
+ const status = find.invalid
84
+ ? 'Invalid pattern'
85
+ : total === 0
86
+ ? find.query
87
+ ? 'No results'
88
+ : ''
89
+ : `${find.currentIndex + 1} of ${total}${find.truncated ? '+' : ''}`;
90
+
91
+ const handleKeyDown = (e: TargetedKeyboardEvent<HTMLInputElement>) => {
92
+ // Escape is NOT handled here: the editor panel owns it on the capture
93
+ // phase (it has to work while focus is in the document too), and a second
94
+ // handler here would be unreachable behind that one.
95
+ // Enter / ⇧Enter step the matches while the focus stays in the field.
96
+ if (e.key === 'Enter' && !e.metaKey && !e.ctrlKey && !e.altKey) {
97
+ e.preventDefault();
98
+ find.step(e.shiftKey ? -1 : 1);
99
+ }
100
+ };
101
+
102
+ /** Tooltip text for a command: its palette label plus the chord it actually listens for. */
103
+ const withChord = (command: EditorCommand, label: string) => {
104
+ const binding = EDITOR_KEY_BINDINGS.find((entry) => entry.command === command);
105
+ return binding ? `${label} (${describeBinding(binding, isMac)})` : label;
106
+ };
107
+
108
+ const optionButton = (key: keyof FindOptions, label: string, command: EditorCommand, icon: ComponentChildren) => (
109
+ <ToggleButton active={find.options[key]} title={withChord(command, label)} onToggle={() => find.toggleOption(key)}>
110
+ {icon}
111
+ </ToggleButton>
112
+ );
113
+
114
+ return (
115
+ <div className="absolute top-2 right-4 z-30 flex flex-col gap-1 bg-paper border border-ink/15 rounded-md shadow-lg p-1.5 text-xs text-ink font-sans w-[330px]">
116
+ <div className="flex items-center gap-1">
117
+ <button
118
+ type="button"
119
+ onClick={find.toggleReplace}
120
+ className={`${BUTTON_CLASS} ${find.replaceOpen ? 'text-ink' : 'text-ink/50 hover:text-ink hover:bg-ink/5'}`}
121
+ title={find.replaceOpen ? 'Hide replace' : 'Show replace'}
122
+ aria-label={find.replaceOpen ? 'Hide replace' : 'Show replace'}
123
+ aria-pressed={find.replaceOpen}
124
+ >
125
+ {find.replaceOpen ? <ChevronUp size={13} /> : <ChevronDown size={13} />}
126
+ </button>
127
+
128
+ <input
129
+ ref={queryRef}
130
+ type="text"
131
+ value={find.query}
132
+ onInput={(e) => find.setQuery(e.currentTarget.value)}
133
+ onKeyDown={handleKeyDown}
134
+ placeholder="Find"
135
+ aria-label="Find"
136
+ className={`flex-1 min-w-0 bg-canvas border rounded px-2 py-1 font-mono text-xs focus:outline-none ${
137
+ find.invalid ? 'border-error text-error' : 'border-ink/20 focus:border-ink text-ink'
138
+ }`}
139
+ />
140
+
141
+ {optionButton('matchCase', 'Match case', 'toggleCaseSensitive', <CaseSensitive size={13} />)}
142
+ {optionButton('wholeWord', 'Match whole word', 'toggleWholeWord', <WholeWord size={13} />)}
143
+ {optionButton('isRegex', 'Use regular expression', 'toggleRegex', <Regex size={13} />)}
144
+
145
+ <span
146
+ className={`font-mono text-[10px] whitespace-nowrap min-w-[52px] text-right ${find.invalid ? 'text-error' : 'text-ink/50'}`}
147
+ aria-live="polite"
148
+ >
149
+ {status}
150
+ </span>
151
+
152
+ <button
153
+ type="button"
154
+ onClick={() => find.step(-1)}
155
+ disabled={total === 0}
156
+ className={`${BUTTON_CLASS} text-ink/50 hover:text-ink hover:bg-ink/5`}
157
+ title={withChord('previousMatch', 'Previous match')}
158
+ aria-label="Previous match"
159
+ >
160
+ <ArrowUp size={13} />
161
+ </button>
162
+ <button
163
+ type="button"
164
+ onClick={() => find.step(1)}
165
+ disabled={total === 0}
166
+ className={`${BUTTON_CLASS} text-ink/50 hover:text-ink hover:bg-ink/5`}
167
+ title={withChord('nextMatch', 'Next match')}
168
+ aria-label="Next match"
169
+ >
170
+ <ArrowDown size={13} />
171
+ </button>
172
+ <button
173
+ type="button"
174
+ onClick={find.close}
175
+ className={`${BUTTON_CLASS} text-ink/50 hover:text-ink hover:bg-ink/5`}
176
+ title="Close find (Escape)"
177
+ aria-label="Close find"
178
+ >
179
+ <X size={13} />
180
+ </button>
181
+ </div>
182
+
183
+ {find.replaceOpen && (
184
+ <div className="flex items-center gap-1">
185
+ <span className="w-[22px]" aria-hidden="true" />
186
+ <input
187
+ type="text"
188
+ value={find.replacement}
189
+ onInput={(e) => find.setReplacement(e.currentTarget.value)}
190
+ onKeyDown={handleKeyDown}
191
+ placeholder="Replace"
192
+ aria-label="Replace"
193
+ className="flex-1 min-w-0 bg-canvas border border-ink/20 rounded px-2 py-1 font-mono text-xs text-ink focus:outline-none focus:border-ink"
194
+ />
195
+ <button
196
+ type="button"
197
+ onClick={find.replaceCurrent}
198
+ disabled={total === 0}
199
+ className={`${BUTTON_CLASS} text-ink/50 hover:text-ink hover:bg-ink/5`}
200
+ title={withChord('replaceOne', 'Replace')}
201
+ aria-label="Replace"
202
+ >
203
+ <Replace size={13} />
204
+ </button>
205
+ <button
206
+ type="button"
207
+ onClick={find.replaceAll}
208
+ disabled={total === 0}
209
+ className={`${BUTTON_CLASS} text-ink/50 hover:text-ink hover:bg-ink/5`}
210
+ title={withChord('replaceAll', 'Replace all')}
211
+ aria-label="Replace all"
212
+ >
213
+ <ReplaceAll size={13} />
214
+ </button>
215
+ </div>
216
+ )}
217
+ </div>
218
+ );
219
+ }
@@ -1,4 +1,4 @@
1
- import { AlertCircle, Check, Copy, Download, Eye, EyeOff, Maximize, Minimize, Save, WrapText, ZoomIn, ZoomOut } from 'lucide-preact';
1
+ import { AlertCircle, Check, Copy, Download, Eye, EyeOff, Maximize, Minimize, Save, Search, WrapText, ZoomIn, ZoomOut } from 'lucide-preact';
2
2
 
3
3
  export type EditorSaveStatus = 'idle' | 'saving' | 'saved' | 'error';
4
4
 
@@ -15,6 +15,9 @@ interface EditorToolbarProps {
15
15
  /** A read failure left no trustworthy buffer, so there is nothing to save. */
16
16
  saveDisabled?: boolean;
17
17
  isMaximized?: boolean;
18
+ /** Find/replace is open — the button reflects it and toggles the bar. */
19
+ findOpen?: boolean;
20
+ onToggleFind: () => void;
18
21
  onSave: () => void;
19
22
  onTogglePreview: () => void;
20
23
  onToggleWordWrap: () => void;
@@ -44,6 +47,8 @@ export function EditorToolbar({
44
47
  isImage = false,
45
48
  saveDisabled = false,
46
49
  isMaximized = false,
50
+ findOpen = false,
51
+ onToggleFind,
47
52
  onSave,
48
53
  onTogglePreview,
49
54
  onToggleWordWrap,
@@ -111,6 +116,19 @@ export function EditorToolbar({
111
116
 
112
117
  {!isImage && (
113
118
  <>
119
+ <div className="flex items-center pr-2 mr-1 border-r border-ink/10">
120
+ <button
121
+ type="button"
122
+ onClick={onToggleFind}
123
+ className={`${ACTION_CLASS} hover:bg-ink/5 ${findOpen ? 'text-ink' : 'hover:text-ink'}`}
124
+ title="Find and replace (Ctrl+F)"
125
+ aria-label="Find and replace"
126
+ aria-pressed={findOpen}
127
+ >
128
+ <Search size={14} />
129
+ </button>
130
+ </div>
131
+
114
132
  <div className="flex items-center pr-2 mr-1 border-r border-ink/10">
115
133
  <button
116
134
  type="button"
@@ -1,17 +1,24 @@
1
- import { useEffect, useState } from 'preact/hooks';
1
+ import { useEffect, useRef, useState } from 'preact/hooks';
2
+ import type { TargetedKeyboardEvent } from 'preact';
2
3
  import { AlertTriangle, Loader2 } from 'lucide-preact';
3
4
  import { FileIcon } from '@/client/components/common/file-icon';
4
- import { CodeSurface } from '@/client/components/common/code-surface';
5
+ import { CodeSurface, type CodeSurfaceHandle } from '@/client/components/common/code-surface';
5
6
  import { MarkdownRenderer } from '@/client/components/common/MarkdownRenderer';
6
7
  import { useScrollbarFade, scrollbarFadeClass } from '@/client/hooks/ui/scrollbar-fade';
7
8
  import { EditorTabs } from '@/client/components/workspace/editor/Tabs';
8
9
  import { EditorToolbar } from '@/client/components/workspace/editor/Toolbar';
10
+ import { FindWidget } from '@/client/components/workspace/editor/FindWidget';
11
+ import { CommandPalette } from '@/client/components/workspace/editor/CommandPalette';
12
+ import { EDITOR_KEY_BINDINGS, isFindBarCommand, type EditorCommand } from '@/shared/lib/code/editor/keymap';
13
+ import type { TextRange } from '@/shared/lib/code/editor/commands';
14
+ import { resolveBinding } from '@/shared/lib/ui/key-binding';
9
15
  import { ImageViewer } from '@/client/components/common/image-viewer';
10
16
  import { DiffPanel } from '@/client/components/workspace/diff-panel';
11
17
  import { useSessionState } from '@/client/hooks/workspace/session-state';
12
18
  import { useSessionStateContext } from '@/client/hooks/workspace/session-state/context';
13
19
  import { getSessionValue } from '@/shared/lib/workspace/session-state/store';
14
20
  import { useFileEditor } from '@/client/hooks/editor/use-file-editor';
21
+ import { useEditorFind } from '@/client/hooks/editor/use-editor-find';
15
22
 
16
23
  interface EditorProps {
17
24
  className?: string;
@@ -41,8 +48,12 @@ export function Editor({
41
48
  const [zoomLevel, setZoomLevel] = useSessionState<number>('editor.zoomLevel', 12);
42
49
  const [isMaximized, setIsMaximized] = useState(false);
43
50
  const [wordWrap, setWordWrap] = useSessionState<boolean>('editor.wordWrap', true);
51
+ /** Extra ⌘D ranges; the surface paints them and the editor edits them by replication. */
52
+ const [occurrences, setOccurrences] = useState<readonly TextRange[]>([]);
53
+ const [paletteOpen, setPaletteOpen] = useState(false);
44
54
  const { sessionId } = useSessionStateContext();
45
55
  const { isScrolling, handleScroll } = useScrollbarFade();
56
+ const surfaceRef = useRef<CodeSurfaceHandle | null>(null);
46
57
 
47
58
  const editor = useFileEditor(activeFile ?? null, {
48
59
  // A file that could not be read must report the failure, not substitute
@@ -52,6 +63,20 @@ export function Editor({
52
63
  onFileSaved,
53
64
  });
54
65
 
66
+ const find = useEditorFind({
67
+ text: editor.content,
68
+ documentKey: `${activeFile?.id ?? ''}\u0000${editor.language}`,
69
+ readSelection: () => surfaceRef.current?.getSelection() ?? null,
70
+ select: (start, end, focus = true) => surfaceRef.current?.select(start, end, focus),
71
+ reveal: (offset) => surfaceRef.current?.revealOffset(offset),
72
+ applyDocument: (value, caretStart, caretEnd) => surfaceRef.current?.applyDocument(value, caretStart, caretEnd),
73
+ toggleWordWrap: () => setWordWrap((previous) => !previous),
74
+ });
75
+ // The capture handler below is rebuilt on every render anyway (it reads the
76
+ // find state), so it is kept in a ref and the listener attached once.
77
+ const findRef = useRef(find);
78
+ findRef.current = find;
79
+
55
80
  useEffect(() => {
56
81
  if (refreshKey > 0) editor.reset();
57
82
  // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -90,6 +115,69 @@ export function Editor({
90
115
  setPreviewMode(prev => ({ ...prev, [activeFile.id]: !prev[activeFile.id] }));
91
116
  };
92
117
 
118
+ /**
119
+ * The one place a command runs, whatever asked for it — a chord, a toolbar
120
+ * button or the palette. Splitting this by source is how a command ends up
121
+ * working from the keyboard but not from the palette (or the reverse).
122
+ */
123
+ const runCommand = (command: EditorCommand) => {
124
+ if (isFindBarCommand(command)) {
125
+ findRef.current.runCommand(command);
126
+ return;
127
+ }
128
+ if (command === 'toggleWordWrap') {
129
+ setWordWrap((previous) => !previous);
130
+ return;
131
+ }
132
+ // Everything else is the editor's, and it runs through the editor's own
133
+ // dispatcher — the palette is a second way IN, never a second implementation.
134
+ surfaceRef.current?.runCommand(command);
135
+ };
136
+
137
+ /**
138
+ * Editor shortcuts, on the CAPTURE phase of the panel.
139
+ *
140
+ * Capture is what makes them work while focus is inside the document: the
141
+ * `<textarea>` has its own handler (Tab, undo, Escape-to-blur) and would
142
+ * otherwise see these first. Only the commands the PANEL owns are consumed
143
+ * here — the editor's own keymap runs in the textarea, where it has the
144
+ * buffer and the caret in hand.
145
+ *
146
+ * The panel is the scope, not the window: another panel's text field keeps
147
+ * its own ⌘F, and the composer is untouched.
148
+ */
149
+ const handlePanelKeyDown = (event: TargetedKeyboardEvent<HTMLDivElement>) => {
150
+ const current = findRef.current;
151
+ if (event.key === 'Escape') {
152
+ if (paletteOpen) {
153
+ event.preventDefault();
154
+ event.stopPropagation();
155
+ setPaletteOpen(false);
156
+ return;
157
+ }
158
+ if (!current.open) return;
159
+ event.preventDefault();
160
+ event.stopPropagation();
161
+ current.close();
162
+ return;
163
+ }
164
+ // ⌘⇧P opens the palette — the one chord that is not an editor command.
165
+ if ((event.metaKey || event.ctrlKey) && event.shiftKey && event.code === 'KeyP') {
166
+ event.preventDefault();
167
+ event.stopPropagation();
168
+ setPaletteOpen(true);
169
+ return;
170
+ }
171
+ // While the bar is open, an unmodified key in one of ITS fields belongs to
172
+ // the field — but F3 and the ⌘-chords below still dispatch, so stepping to
173
+ // the next match from inside the find field works.
174
+ const command = resolveBinding(EDITOR_KEY_BINDINGS, event);
175
+ if (!command || !isFindBarCommand(command)) return;
176
+ event.preventDefault();
177
+ event.stopPropagation();
178
+ runCommand(command);
179
+ };
180
+
93
181
  if (openedFiles.length === 0) {
94
182
  return (
95
183
  <div className={`flex flex-col h-full bg-canvas items-center justify-center text-ink/40 ${className}`}>
@@ -108,7 +196,7 @@ export function Editor({
108
196
  : `flex flex-col h-full bg-canvas ${className}`;
109
197
 
110
198
  return (
111
- <div className={editorContainerClass}>
199
+ <div className={editorContainerClass} onKeyDownCapture={handlePanelKeyDown}>
112
200
  <EditorTabs
113
201
  openedFiles={openedFiles}
114
202
  activeFileId={activeFileId}
@@ -140,6 +228,8 @@ export function Editor({
140
228
  isImage={editor.isImage}
141
229
  saveDisabled={Boolean(editor.loadError)}
142
230
  isMaximized={isMaximized}
231
+ findOpen={find.open}
232
+ onToggleFind={() => (find.open ? find.close() : find.openFind())}
143
233
  onSave={editor.saveNow}
144
234
  onTogglePreview={togglePreview}
145
235
  onToggleWordWrap={() => setWordWrap(!wordWrap)}
@@ -170,57 +260,67 @@ export function Editor({
170
260
  <span className="text-[11px] text-ink/50 font-mono break-all">{editor.loadError}</span>
171
261
  </div>
172
262
  ) : (
173
- <div onScroll={handleScroll} className={`flex-1 overflow-auto bg-paper flex ${scrollbarFadeClass(isScrolling)}`}>
174
- {(isMd && isPreview) ? (
175
- // No `prose` wrapper: markdown is styled by `.prose-content`
176
- // alone (the same system the chat timeline uses). The
177
- // typography plugin fought it — `.prose img` added 2em
178
- // vertical margins that ballooned a badge row into its own
179
- // line box, and `--tw-prose-*` colors ignored the theme.
180
- <div className="p-6 max-w-4xl mx-auto font-sans flex-1" style={{ fontSize: `${zoomLevel}px` }}>
181
- <MarkdownRenderer
182
- content={currentContent}
183
- document
184
- scope={{ path: activeFile.path, root: activeFile.root, repo: activeFile.repo }}
263
+ <div className="relative flex-1 min-h-0 flex flex-col">
264
+ {find.open ? <FindWidget find={find} /> : null}
265
+ {paletteOpen ? <CommandPalette onRun={runCommand} onClose={() => setPaletteOpen(false)} /> : null}
266
+ <div onScroll={handleScroll} className={`flex-1 overflow-auto bg-paper flex ${scrollbarFadeClass(isScrolling)}`}>
267
+ {(isMd && isPreview) ? (
268
+ // No `prose` wrapper: markdown is styled by `.prose-content`
269
+ // alone (the same system the chat timeline uses). The
270
+ // typography plugin fought it — `.prose img` added 2em
271
+ // vertical margins that ballooned a badge row into its own
272
+ // line box, and `--tw-prose-*` colors ignored the theme.
273
+ <div className="p-6 max-w-4xl mx-auto font-sans flex-1" style={{ fontSize: `${zoomLevel}px` }}>
274
+ <MarkdownRenderer
275
+ content={currentContent}
276
+ document
277
+ scope={{ path: activeFile.path, root: activeFile.root, repo: activeFile.repo }}
278
+ />
279
+ </div>
280
+ ) : (
281
+ <CodeSurface
282
+ ref={surfaceRef}
283
+ value={currentContent}
284
+ onValueChange={editor.onChange}
285
+ language={editor.language}
286
+ wordWrap={wordWrap}
287
+ marks={find.open ? find.matches : undefined}
288
+ currentMark={find.currentIndex}
289
+ occurrences={occurrences}
290
+ onOccurrencesChange={setOccurrences}
291
+ onCommand={runCommand}
292
+ rootClassName="flex"
293
+ gutterClassName="flex flex-col text-right pl-4 pr-3 select-none text-ink/30 font-mono border-r border-ink/10 bg-canvas sticky left-0 z-10"
294
+ gutterStyle={{
295
+ fontSize: zoomLevel,
296
+ paddingTop: 16,
297
+ paddingBottom: 16,
298
+ lineHeight: 1.5,
299
+ fontFamily: '"Fira Code", "JetBrains Mono", "SF Mono", Consolas, monospace',
300
+ }}
301
+ gutterLineClassName="min-w-[1.5rem]"
302
+ // `min-w-max` keeps a long line intact and lets the panel
303
+ // scroll sideways; while wrapping it would instead widen the
304
+ // column past the panel, so the toggle had no effect at all.
305
+ editorWrapperClassName={`flex-1 code-surface ${wordWrap ? 'min-w-0' : 'min-w-max'}`}
306
+ editorPadding={16}
307
+ editorClassName="font-mono focus:outline-none"
308
+ editorStyle={{
309
+ fontFamily: '"Fira Code", "JetBrains Mono", "SF Mono", Consolas, monospace',
310
+ fontSize: zoomLevel,
311
+ lineHeight: 1.5,
312
+ minHeight: '100%',
313
+ // Breathing room under the last line. It has to live on the
314
+ // surface, not on the scroll container: a scroll container's
315
+ // own bottom padding is not part of its scrollable overflow,
316
+ // so `pb-*` on the scroller left the last line flush with the
317
+ // panel's edge (measured: padding-bottom 32px did not change
318
+ // scrollHeight by a single pixel).
319
+ paddingBottom: 16,
320
+ }}
185
321
  />
186
- </div>
187
- ) : (
188
- <CodeSurface
189
- value={currentContent}
190
- onValueChange={editor.onChange}
191
- language={editor.language}
192
- wordWrap={wordWrap}
193
- rootClassName="flex"
194
- gutterClassName="flex flex-col text-right pl-4 pr-3 select-none text-ink/30 font-mono border-r border-ink/10 bg-canvas sticky left-0 z-10"
195
- gutterStyle={{
196
- fontSize: zoomLevel,
197
- paddingTop: 16,
198
- paddingBottom: 16,
199
- lineHeight: 1.5,
200
- fontFamily: '"Fira Code", "JetBrains Mono", "SF Mono", Consolas, monospace',
201
- }}
202
- gutterLineClassName="min-w-[1.5rem]"
203
- // `min-w-max` keeps a long line intact and lets the panel
204
- // scroll sideways; while wrapping it would instead widen the
205
- // column past the panel, so the toggle had no effect at all.
206
- editorWrapperClassName={`flex-1 code-surface ${wordWrap ? 'min-w-0' : 'min-w-max'}`}
207
- editorPadding={16}
208
- editorClassName="font-mono focus:outline-none"
209
- editorStyle={{
210
- fontFamily: '"Fira Code", "JetBrains Mono", "SF Mono", Consolas, monospace',
211
- fontSize: zoomLevel,
212
- lineHeight: 1.5,
213
- minHeight: '100%',
214
- // Breathing room under the last line. It has to live on the
215
- // surface, not on the scroll container: a scroll container's
216
- // own bottom padding is not part of its scrollable overflow,
217
- // so `pb-*` on the scroller left the last line flush with the
218
- // panel's edge (measured: padding-bottom 32px did not change
219
- // scrollHeight by a single pixel).
220
- paddingBottom: 16,
221
- }}
222
- />
223
- )}
322
+ )}
323
+ </div>
224
324
  </div>
225
325
  )}
226
326
  </>
@@ -1,6 +1,6 @@
1
1
  import { createPortal } from 'preact/compat';
2
2
  import type { FormEvent, RefObject } from 'preact/compat';
3
- import { Copy, Edit2, ExternalLink, Eye, GitCompare, History, RefreshCw, Trash2, X } from 'lucide-preact';
3
+ import { Copy, Edit2, ExternalLink, Eye, FilePlus, GitCompare, History, RefreshCw, Trash2, X } from 'lucide-preact';
4
4
 
5
5
  interface ContextMenuProps {
6
6
  x: number;
@@ -13,7 +13,9 @@ interface ContextMenuProps {
13
13
 
14
14
  export function FileContextMenu({ x, y, isFolder, hasGitStatus, onAction, menuRef }: ContextMenuProps) {
15
15
  const menuWidth = 190;
16
- const menuHeight = isFolder ? 220 : hasGitStatus ? 285 : 255;
16
+ // The row count differs by kind, and the clamp below needs the real height —
17
+ // an under-estimate puts the menu's last rows off-screen at the bottom edge.
18
+ const menuHeight = isFolder ? 250 : hasGitStatus ? 285 : 255;
17
19
 
18
20
  const posX = typeof window !== 'undefined' ? Math.max(10, Math.min(x, window.innerWidth - menuWidth - 12)) : x;
19
21
  const posY = typeof window !== 'undefined' ? Math.max(10, Math.min(y, window.innerHeight - menuHeight - 12)) : y;
@@ -44,6 +46,15 @@ export function FileContextMenu({ x, y, isFolder, hasGitStatus, onAction, menuRe
44
46
  )}
45
47
  </>
46
48
  )}
49
+ {isFolder && (
50
+ <>
51
+ <button onClick={() => onAction('new_file')} className="w-full text-left px-3 py-1.5 hover:bg-ink/5 flex items-center space-x-2 transition-colors">
52
+ <FilePlus size={13} className="text-ink/60" />
53
+ <span>New File</span>
54
+ </button>
55
+ <div className="my-1 border-t border-ink/10"></div>
56
+ </>
57
+ )}
47
58
  <button onClick={() => onAction('explorer')} className="w-full text-left px-3 py-1.5 hover:bg-ink/5 flex items-center space-x-2 transition-colors">
48
59
  <ExternalLink size={13} className="text-ink/60" />
49
60
  <span>Go to Explorer</span>
@@ -77,6 +88,72 @@ export function FileContextMenu({ x, y, isFolder, hasGitStatus, onAction, menuRe
77
88
  );
78
89
  }
79
90
 
91
+ interface NewFileModalProps {
92
+ /** Folder the file will be created in, relative to the listed root. */
93
+ folderPath: string;
94
+ value: string;
95
+ /** Server's refusal (a name that already exists, a bad character), shown inline. */
96
+ error?: string | null;
97
+ isLoading: boolean;
98
+ onChange: (value: string) => void;
99
+ onCancel: () => void;
100
+ onSubmit: (e: FormEvent) => void;
101
+ }
102
+
103
+ /**
104
+ * The "New File" dialog.
105
+ *
106
+ * Only a name is asked for — the folder is the one that was right-clicked, and
107
+ * showing it as a fixed prefix is what makes the field's meaning unambiguous.
108
+ * The input starts empty (no `.txt` default): a name is what the user has in
109
+ * mind, and a prefilled suffix is a character to delete before every keystroke
110
+ * lands. The server's refusal is rendered INLINE rather than as a toast: the
111
+ * field is still open, and "already exists" is a message about what to type
112
+ * next, not a notification about something that happened.
113
+ */
114
+ export function FileCreateModal({ folderPath, value, error, isLoading, onChange, onCancel, onSubmit }: NewFileModalProps) {
115
+ return createPortal(
116
+ <div className="fixed inset-0 bg-ink/20 z-[100] flex items-center justify-center p-4 backdrop-blur-sm">
117
+ <form onSubmit={onSubmit} className="bg-paper rounded shadow-xl p-4 w-full max-w-sm border border-ink/10 font-sans">
118
+ <h3 className="font-semibold text-ink mb-3 text-sm">New File</h3>
119
+ <div className="flex items-center gap-1 mb-1 text-xs font-mono text-ink/50 min-w-0">
120
+ <span className="truncate" title={folderPath}>{folderPath || '.'}/</span>
121
+ </div>
122
+ <input
123
+ autoFocus
124
+ type="text"
125
+ value={value}
126
+ onChange={e => onChange(e.currentTarget.value)}
127
+ placeholder="file name"
128
+ aria-label="New file name"
129
+ className={`w-full bg-paper border rounded px-2 py-1.5 text-xs font-mono focus:outline-none mb-2 ${
130
+ error ? 'border-error' : 'border-ink/20 focus:border-ink'
131
+ }`}
132
+ />
133
+ {error && <p className="text-[11px] text-error mb-2">{error}</p>}
134
+ <div className="flex justify-end space-x-2 mt-2">
135
+ <button
136
+ type="button"
137
+ onClick={onCancel}
138
+ className="px-3 py-1.5 text-xs text-ink hover:bg-ink/5 rounded transition-colors"
139
+ disabled={isLoading}
140
+ >
141
+ Cancel
142
+ </button>
143
+ <button
144
+ type="submit"
145
+ className="px-3 py-1.5 text-xs bg-ink text-canvas rounded hover:bg-ink/90 transition-colors flex items-center"
146
+ disabled={isLoading || !value.trim()}
147
+ >
148
+ {isLoading ? 'Creating...' : 'Create'}
149
+ </button>
150
+ </div>
151
+ </form>
152
+ </div>,
153
+ document.body
154
+ );
155
+ }
156
+
80
157
  interface DeleteModalProps {
81
158
  fileName: string;
82
159
  isFolder: boolean;