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.
- package/README.md +24 -5
- package/dist/client/index.html +1 -1
- package/dist/client/index.js +333 -333
- package/dist/client/static/js/{index-prbmx3yj.js → index-8t70jhyj.js} +643 -617
- package/dist/client/static/js/index-n4wkqj7t.css +1 -0
- package/package.json +1 -1
- package/src/client/components/common/code-editor/handler.test.ts +274 -0
- package/src/client/components/common/code-editor/handler.ts +175 -0
- package/src/client/components/common/code-editor/index.tsx +185 -220
- package/src/client/components/common/code-surface/index.tsx +109 -12
- package/src/client/components/common/lazy-panels.tsx +5 -1
- package/src/client/components/common/viewport.ts +5 -3
- package/src/client/components/layout/RightActivityBar.tsx +2 -1
- package/src/client/components/layout/desktop-layout/TopNavbar.tsx +21 -4
- package/src/client/components/layout/desktop-layout/index.tsx +24 -4
- package/src/client/components/mobile/RightSidebar.tsx +11 -4
- package/src/client/components/workspace/browser-panel/AddressBar.tsx +1 -10
- package/src/client/components/workspace/browser-panel/Screencast.tsx +4 -5
- package/src/client/components/workspace/browser-panel/index.tsx +6 -6
- package/src/client/components/workspace/browser-toolbar/index.tsx +22 -12
- package/src/client/components/workspace/chat-timeline/ToolCallCard.tsx +1 -1
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Todo.tsx +1 -1
- package/src/client/components/workspace/editor/CommandPalette.test.ts +242 -0
- package/src/client/components/workspace/editor/CommandPalette.tsx +138 -0
- package/src/client/components/workspace/editor/FindWidget.test.ts +261 -0
- package/src/client/components/workspace/editor/FindWidget.tsx +219 -0
- package/src/client/components/workspace/editor/Toolbar.tsx +19 -1
- package/src/client/components/workspace/editor/index.tsx +153 -53
- package/src/client/components/workspace/file-explorer/Modals.tsx +79 -2
- package/src/client/components/workspace/file-explorer/TreeItem.tsx +43 -113
- package/src/client/components/workspace/todo-panel/PhaseGroup.tsx +76 -0
- package/src/client/components/workspace/todo-panel/Row.tsx +69 -0
- package/src/client/components/workspace/todo-panel/index.tsx +161 -0
- package/src/client/hooks/editor/find-harness.test-util.ts +170 -0
- package/src/client/hooks/editor/scroll-container.test.ts +102 -0
- package/src/client/hooks/editor/scroll-container.ts +26 -0
- package/src/client/hooks/editor/use-code-window.ts +2 -20
- package/src/client/hooks/editor/use-editor-find-write.test.ts +170 -0
- package/src/client/hooks/editor/use-editor-find.test.ts +118 -0
- package/src/client/hooks/editor/use-editor-find.ts +321 -0
- package/src/client/hooks/editor/use-editor-history.test.ts +216 -0
- package/src/client/hooks/editor/use-editor-history.ts +134 -0
- package/src/client/hooks/editor/use-occurrences.test.ts +236 -0
- package/src/client/hooks/editor/use-occurrences.ts +98 -0
- package/src/client/hooks/workspace/file-tree-actions.test.ts +348 -0
- package/src/client/hooks/workspace/file-tree-actions.ts +250 -0
- package/src/client/hooks/workspace/session-todos.ts +102 -0
- package/src/client/tailwind.css +30 -0
- package/src/server/lib/omp/session/todos.ts +100 -0
- package/src/server/routes/fs/action-create.test.ts +90 -0
- package/src/server/routes/fs/action.ts +23 -0
- package/src/server/routes/omp/index.ts +2 -0
- package/src/server/routes/omp/session-todos.ts +80 -0
- package/src/shared/lib/chat/{todo-parser.ts → todo/parser.ts} +17 -0
- package/src/shared/lib/chat/todo/progress.ts +97 -0
- package/src/shared/lib/chat/todo/snapshot.test.ts +156 -0
- package/src/shared/lib/chat/todo/snapshot.ts +215 -0
- package/src/shared/lib/code/editor/commands.test.ts +155 -0
- package/src/shared/lib/code/editor/commands.ts +260 -0
- package/src/shared/lib/code/editor/edits.test.ts +100 -0
- package/src/shared/lib/code/editor/edits.ts +122 -0
- package/src/shared/lib/code/editor/find-status.test.ts +50 -0
- package/src/shared/lib/code/editor/find-status.ts +36 -0
- package/src/shared/lib/code/editor/find.test.ts +203 -0
- package/src/shared/lib/code/editor/find.ts +224 -0
- package/src/{client/components/common/code-editor → shared/lib/code/editor}/history.test.ts +1 -1
- package/src/shared/lib/code/editor/keymap.test.ts +109 -0
- package/src/shared/lib/code/editor/keymap.ts +151 -0
- package/src/shared/lib/code/editor/layers.test.ts +77 -0
- package/src/shared/lib/code/editor/layers.ts +67 -0
- package/src/shared/lib/code/editor/line-edits.test.ts +131 -0
- package/src/shared/lib/code/editor/line-edits.ts +159 -0
- package/src/shared/lib/code/editor/lines.test.ts +81 -0
- package/src/shared/lib/code/editor/lines.ts +83 -0
- package/src/shared/lib/code/editor/marks.test.ts +86 -0
- package/src/shared/lib/code/editor/marks.ts +106 -0
- package/src/shared/lib/code/editor/occurrences.test.ts +117 -0
- package/src/shared/lib/code/editor/occurrences.ts +111 -0
- package/src/shared/lib/code/editor/palette.test.ts +91 -0
- package/src/shared/lib/code/editor/palette.ts +92 -0
- package/src/shared/lib/code/editor/reset-css.test.ts +35 -0
- package/src/shared/lib/code/editor/reset-css.ts +32 -0
- package/src/shared/lib/code/editor/text-commands.test.ts +178 -0
- package/src/shared/lib/code/editor/text-commands.ts +154 -0
- package/src/shared/lib/code/syntax-highlight.test.ts +75 -0
- package/src/shared/lib/code/syntax-highlight.ts +29 -17
- package/src/shared/lib/code/windowed-highlight.ts +52 -1
- package/src/shared/lib/ui/key-binding.test.ts +115 -0
- package/src/shared/lib/ui/key-binding.ts +143 -0
- package/src/shared/lib/util/platform.test.ts +87 -0
- package/src/shared/lib/util/platform.ts +30 -0
- package/src/shared/lib/workspace/keymap.test.ts +82 -0
- package/src/shared/lib/workspace/keymap.ts +30 -0
- package/src/shared/lib/workspace/refresh-cadence.ts +9 -0
- package/src/shared/lib/workspace/right-panels.ts +8 -0
- package/src/shared/lib/workspace/session-state/store.ts +0 -1
- package/src/shared/types/index.ts +1 -0
- package/src/shared/types/todo.ts +108 -0
- package/dist/client/static/js/index-ranztes9.css +0 -1
- /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
|
|
174
|
-
{
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|