ompchamber 2.0.0 → 2.0.2
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 +12 -0
- package/dist/client/index.html +1 -1
- package/dist/client/static/js/async/{g.74ff36419f.js → g.cb81980498.js} +2 -2
- package/dist/client/static/js/async/{q.33d6779f98.js → q.22588845a9.js} +2 -2
- package/dist/client/static/js/async/{yx.af2936c368.js → xn1.83a7784fe1.js} +3 -3
- package/dist/client/static/js/{index.c05535dbde.js → index.e6a257402a.js} +2 -2
- package/package.json +1 -1
- package/src/client/components/workspace/diff-panel/SplitView.tsx +4 -1
- package/src/client/components/workspace/diff-panel/UnifiedView.tsx +70 -65
- package/src/client/components/workspace/diff-panel/index.tsx +8 -1
- package/src/client/components/workspace/editor/index.tsx +7 -0
- package/src/client/hooks/editor/save-file.ts +62 -0
- package/src/client/hooks/editor/use-file-editor.test.ts +61 -5
- package/src/client/hooks/editor/use-file-editor.ts +38 -29
- package/src/client/hooks/editor/use-file-transfer.ts +5 -2
- package/src/server/routes/fs/action.test.ts +64 -0
- package/src/server/routes/fs/action.ts +10 -1
- package/src/shared/lib/code/line-endings.test.ts +38 -0
- package/src/shared/lib/code/line-endings.ts +56 -0
- /package/dist/client/static/js/async/{g.74ff36419f.js.LICENSE.txt → g.cb81980498.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{q.33d6779f98.js.LICENSE.txt → q.22588845a9.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{yx.af2936c368.js.LICENSE.txt → xn1.83a7784fe1.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/{index.c05535dbde.js.LICENSE.txt → index.e6a257402a.js.LICENSE.txt} +0 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ompchamber",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.2",
|
|
4
4
|
"description": "Developer web console and diagnostic chamber for AI Oh-My-Pi — Elysia + Preact on Bun.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"author": "Wijanarko Putra Rajeb <wijanarko.rajeb@gmail.com>",
|
|
@@ -44,7 +44,10 @@ export function SplitView({ rows, language }: SplitViewProps) {
|
|
|
44
44
|
|
|
45
45
|
return (
|
|
46
46
|
<div className="w-full h-full overflow-auto font-mono text-xs select-text bg-paper text-ink code-surface">
|
|
47
|
-
|
|
47
|
+
{/* The room under the last row belongs to the content, not the scroller:
|
|
48
|
+
a scroll container's own bottom padding is not part of its scrollable
|
|
49
|
+
overflow, so the padding has to sit inside it to have any effect. */}
|
|
50
|
+
<div className="w-max min-w-full min-w-[700px] pb-4">
|
|
48
51
|
<div className="grid grid-cols-2 sticky top-0 z-10 bg-canvas border-b border-ink/10 text-[11px] text-ink/70 font-sans select-none shadow-xs">
|
|
49
52
|
<div className="px-3 py-1 font-medium border-r border-ink/10 flex items-center justify-between">
|
|
50
53
|
<span>Original (HEAD / Base)</span>
|
|
@@ -38,80 +38,85 @@ export function UnifiedView({ lines, language }: UnifiedViewProps) {
|
|
|
38
38
|
|
|
39
39
|
return (
|
|
40
40
|
<div className="w-full h-full overflow-auto font-mono text-xs select-text bg-paper text-ink code-surface">
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
41
|
+
{/* The room under the last row belongs to the content, not the scroller:
|
|
42
|
+
a scroll container's own bottom padding is not part of its scrollable
|
|
43
|
+
overflow, so the padding has to sit inside it to have any effect. */}
|
|
44
|
+
<div className="pb-4">
|
|
45
|
+
<table className="w-max min-w-full border-collapse">
|
|
46
|
+
<tbody>
|
|
47
|
+
{lines.map((line, idx) => {
|
|
48
|
+
if (line.type === 'meta') {
|
|
49
|
+
return (
|
|
50
|
+
<tr key={idx} className="bg-canvas border-y border-ink/10 text-meta select-none">
|
|
51
|
+
<td className="w-12 text-center py-1 px-2 text-[10px] opacity-60 border-r border-ink/10 font-mono">...</td>
|
|
52
|
+
<td className="w-12 text-center py-1 px-2 text-[10px] opacity-60 border-r border-ink/10 font-mono">...</td>
|
|
53
|
+
<td className="py-1 px-3 text-[11px] font-semibold text-meta font-mono tracking-tight">{line.text}</td>
|
|
54
|
+
</tr>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
53
57
|
|
|
54
|
-
|
|
55
|
-
|
|
58
|
+
const isAdd = line.type === 'add';
|
|
59
|
+
const isDel = line.type === 'del';
|
|
56
60
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
isAdd
|
|
62
|
-
? 'bg-success/10 hover:bg-success/15'
|
|
63
|
-
: isDel
|
|
64
|
-
? 'bg-error/10 hover:bg-error/15'
|
|
65
|
-
: 'hover:bg-ink/[0.03]'
|
|
66
|
-
}`}
|
|
67
|
-
>
|
|
68
|
-
{/* Old line number */}
|
|
69
|
-
<td
|
|
70
|
-
className={`w-12 py-0.5 px-2 text-right text-[10px] select-none border-r border-ink/10 font-mono ${
|
|
71
|
-
isDel
|
|
72
|
-
? 'bg-error/15 text-error font-semibold'
|
|
73
|
-
: isAdd
|
|
74
|
-
? 'bg-success/15 text-success/70'
|
|
75
|
-
: 'bg-canvas/50 text-ink/40'
|
|
76
|
-
}`}
|
|
77
|
-
>
|
|
78
|
-
{line.oldLineNumber || ''}
|
|
79
|
-
</td>
|
|
80
|
-
|
|
81
|
-
{/* New line number */}
|
|
82
|
-
<td
|
|
83
|
-
className={`w-12 py-0.5 px-2 text-right text-[10px] select-none border-r border-ink/10 font-mono ${
|
|
61
|
+
return (
|
|
62
|
+
<tr
|
|
63
|
+
key={idx}
|
|
64
|
+
className={`transition-colors group ${
|
|
84
65
|
isAdd
|
|
85
|
-
? 'bg-success/
|
|
66
|
+
? 'bg-success/10 hover:bg-success/15'
|
|
86
67
|
: isDel
|
|
87
|
-
? 'bg-error/
|
|
88
|
-
: 'bg-
|
|
68
|
+
? 'bg-error/10 hover:bg-error/15'
|
|
69
|
+
: 'hover:bg-ink/[0.03]'
|
|
89
70
|
}`}
|
|
90
71
|
>
|
|
91
|
-
{line
|
|
92
|
-
|
|
72
|
+
{/* Old line number */}
|
|
73
|
+
<td
|
|
74
|
+
className={`w-12 py-0.5 px-2 text-right text-[10px] select-none border-r border-ink/10 font-mono ${
|
|
75
|
+
isDel
|
|
76
|
+
? 'bg-error/15 text-error font-semibold'
|
|
77
|
+
: isAdd
|
|
78
|
+
? 'bg-success/15 text-success/70'
|
|
79
|
+
: 'bg-canvas/50 text-ink/40'
|
|
80
|
+
}`}
|
|
81
|
+
>
|
|
82
|
+
{line.oldLineNumber || ''}
|
|
83
|
+
</td>
|
|
93
84
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
85
|
+
{/* New line number */}
|
|
86
|
+
<td
|
|
87
|
+
className={`w-12 py-0.5 px-2 text-right text-[10px] select-none border-r border-ink/10 font-mono ${
|
|
88
|
+
isAdd
|
|
89
|
+
? 'bg-success/15 text-success font-semibold'
|
|
90
|
+
: isDel
|
|
91
|
+
? 'bg-error/15 text-error/70'
|
|
92
|
+
: 'bg-canvas/50 text-ink/40'
|
|
99
93
|
}`}
|
|
100
94
|
>
|
|
101
|
-
{
|
|
102
|
-
</
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
95
|
+
{line.newLineNumber || ''}
|
|
96
|
+
</td>
|
|
97
|
+
|
|
98
|
+
{/* Change prefix & code text */}
|
|
99
|
+
<td className="py-0.5 px-3 whitespace-pre text-ink pr-6">
|
|
100
|
+
<span
|
|
101
|
+
className={`inline-block w-4 text-center select-none font-bold ${
|
|
102
|
+
isAdd ? 'text-success' : isDel ? 'text-error' : 'opacity-0'
|
|
103
|
+
}`}
|
|
104
|
+
>
|
|
105
|
+
{isAdd ? '+' : isDel ? '-' : ' '}
|
|
106
|
+
</span>
|
|
107
|
+
<span
|
|
108
|
+
className="shiki"
|
|
109
|
+
dangerouslySetInnerHTML={{
|
|
110
|
+
__html: line.text ? htmlByIndex[idx] || ' ' : ' ',
|
|
111
|
+
}}
|
|
112
|
+
/>
|
|
113
|
+
</td>
|
|
114
|
+
</tr>
|
|
115
|
+
);
|
|
116
|
+
})}
|
|
117
|
+
</tbody>
|
|
118
|
+
</table>
|
|
119
|
+
</div>
|
|
115
120
|
</div>
|
|
116
121
|
);
|
|
117
122
|
}
|
|
@@ -135,7 +135,14 @@ export function DiffPanel({
|
|
|
135
135
|
};
|
|
136
136
|
|
|
137
137
|
return (
|
|
138
|
-
|
|
138
|
+
// The panel fills, it does not size itself: the caller owns the height
|
|
139
|
+
// (`flex-1` in the editor's column, `h-full` in the mobile overlay). A
|
|
140
|
+
// hardcoded `h-full` here resolves against the whole editor column rather
|
|
141
|
+
// than the space left under the tabs, so the panel began below the tabs
|
|
142
|
+
// *and* stood the full column tall — its bottom, the toolbar's height,
|
|
143
|
+
// hung past the panel edge and was clipped for good, since the scroll
|
|
144
|
+
// range ends below the visible area.
|
|
145
|
+
<div className={`flex flex-col min-h-0 bg-paper select-text relative font-sans ${className}`}>
|
|
139
146
|
{/* Top Diff Toolbar */}
|
|
140
147
|
<DiffToolbar
|
|
141
148
|
filePath={filePath}
|
|
@@ -193,6 +193,13 @@ export function Editor({
|
|
|
193
193
|
fontSize: zoomLevel,
|
|
194
194
|
lineHeight: 1.5,
|
|
195
195
|
minHeight: '100%',
|
|
196
|
+
// Breathing room under the last line. It has to live on the
|
|
197
|
+
// surface, not on the scroll container: a scroll container's
|
|
198
|
+
// own bottom padding is not part of its scrollable overflow,
|
|
199
|
+
// so `pb-*` on the scroller left the last line flush with the
|
|
200
|
+
// panel's edge (measured: padding-bottom 32px did not change
|
|
201
|
+
// scrollHeight by a single pixel).
|
|
202
|
+
paddingBottom: 16,
|
|
196
203
|
}}
|
|
197
204
|
/>
|
|
198
205
|
)}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The write half of the file-editing contract: POST one buffer to
|
|
8
|
+
* `/api/fs/action` and report whether the server took it.
|
|
9
|
+
*
|
|
10
|
+
* Kept out of `useFileEditor` because the multipart shape, the scope fields and
|
|
11
|
+
* the failure rule are one self-contained concern, and because the hook carries
|
|
12
|
+
* the read, cache and history concerns already.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { LineEnding } from '@/shared/lib/code/line-endings';
|
|
16
|
+
|
|
17
|
+
interface SavableFile {
|
|
18
|
+
path?: string;
|
|
19
|
+
root?: string;
|
|
20
|
+
repo?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Writes `lfText` to `file.path` in the file's own `eol`. Returns false on any
|
|
25
|
+
* refusal — a non-2xx response, an `error` body, or a dropped connection —
|
|
26
|
+
* because the caller's only decision is whether to mark the buffer clean.
|
|
27
|
+
*
|
|
28
|
+
* The ending travels as its own field and the server rebuilds the bytes: a
|
|
29
|
+
* multipart body normalizes bare LF to CRLF, so the payload cannot carry the
|
|
30
|
+
* distinction itself.
|
|
31
|
+
*/
|
|
32
|
+
export async function saveEditorFile(file: SavableFile, lfText: string, eol: LineEnding): Promise<boolean> {
|
|
33
|
+
if (!file.path) return false;
|
|
34
|
+
const formData = new FormData();
|
|
35
|
+
formData.append('actionType', 'save');
|
|
36
|
+
formData.append('path', file.path);
|
|
37
|
+
formData.append('content', lfText);
|
|
38
|
+
formData.append('eol', eol);
|
|
39
|
+
if (file.root) formData.append('root', file.root);
|
|
40
|
+
if (file.repo && file.repo !== '.') formData.append('repo', file.repo);
|
|
41
|
+
try {
|
|
42
|
+
const res = await fetch('/api/fs/action', { method: 'POST', body: formData });
|
|
43
|
+
const data = await res.json().catch(() => null);
|
|
44
|
+
return Boolean(res.ok && data?.success);
|
|
45
|
+
} catch {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Keeps only `keep`'s keys, returning the same object when nothing was
|
|
52
|
+
* dropped so a render whose tab set is unchanged does not re-key its children.
|
|
53
|
+
*/
|
|
54
|
+
export function retainKeys<T>(record: Record<string, T>, keep: Set<string>): Record<string, T> {
|
|
55
|
+
const keys = Object.keys(record);
|
|
56
|
+
if (keys.every((key) => keep.has(key))) return record;
|
|
57
|
+
const next: Record<string, T> = {};
|
|
58
|
+
for (const key of keys) {
|
|
59
|
+
if (keep.has(key)) next[key] = record[key];
|
|
60
|
+
}
|
|
61
|
+
return next;
|
|
62
|
+
}
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
* bind their environment at evaluation time.
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
|
-
import { afterAll, beforeAll, beforeEach, describe, expect, test } from 'bun:test';
|
|
22
|
+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from 'bun:test';
|
|
23
23
|
import { Window } from 'happy-dom';
|
|
24
24
|
import type { h as PreactH, render as PreactRender } from 'preact';
|
|
25
25
|
import type { act as PreactAct } from 'preact/test-utils';
|
|
@@ -42,17 +42,21 @@ interface FsState {
|
|
|
42
42
|
reads: number;
|
|
43
43
|
writes: number;
|
|
44
44
|
body: string;
|
|
45
|
+
/** The `eol` field of the last write — the ending the server is asked to use. */
|
|
46
|
+
sentEol: string | null;
|
|
45
47
|
}
|
|
46
48
|
|
|
47
|
-
/** Serves `state.body` for reads and
|
|
49
|
+
/** Serves `state.body` for reads and records writes, without touching the network. */
|
|
48
50
|
function stubFetch(state: FsState) {
|
|
49
51
|
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
|
|
50
52
|
if (String(input).startsWith('/api/fs/read')) {
|
|
51
53
|
state.reads += 1;
|
|
52
54
|
return { ok: true, json: async () => ({ content: state.body }) };
|
|
53
55
|
}
|
|
56
|
+
const form = init?.body as FormData;
|
|
54
57
|
state.writes += 1;
|
|
55
|
-
state.body = String(
|
|
58
|
+
state.body = String(form.get('content'));
|
|
59
|
+
state.sentEol = form.get('eol') === null ? null : String(form.get('eol'));
|
|
56
60
|
return { ok: true, json: async () => ({ success: true }) };
|
|
57
61
|
}) as unknown as typeof fetch;
|
|
58
62
|
}
|
|
@@ -123,9 +127,18 @@ beforeEach(() => {
|
|
|
123
127
|
document.body.appendChild(container);
|
|
124
128
|
});
|
|
125
129
|
|
|
130
|
+
// Unmount while the DOM globals still exist, so the hook's effect cleanups run
|
|
131
|
+
// here. A landed save arms a timer that returns the status to `idle`; an effect
|
|
132
|
+
// timer that outlived this file would fire during the next file's test and, with
|
|
133
|
+
// `window` already removed by `afterAll`, break its render.
|
|
134
|
+
afterEach(() => {
|
|
135
|
+
render(null, container);
|
|
136
|
+
container.remove();
|
|
137
|
+
});
|
|
138
|
+
|
|
126
139
|
describe('useFileEditor revalidation', () => {
|
|
127
140
|
test('reset re-reads from disk without blanking the open file', async () => {
|
|
128
|
-
const state: FsState = { reads: 0, writes: 0, body: DISK };
|
|
141
|
+
const state: FsState = { reads: 0, writes: 0, sentEol: null, body: DISK };
|
|
129
142
|
stubFetch(state);
|
|
130
143
|
await mountEditor();
|
|
131
144
|
expect(mounted?.content).toBe(DISK);
|
|
@@ -142,7 +155,7 @@ describe('useFileEditor revalidation', () => {
|
|
|
142
155
|
});
|
|
143
156
|
|
|
144
157
|
test('a read that lands mid-typing does not replace the buffer', async () => {
|
|
145
|
-
const state: FsState = { reads: 0, writes: 0, body: DISK };
|
|
158
|
+
const state: FsState = { reads: 0, writes: 0, sentEol: null, body: DISK };
|
|
146
159
|
stubFetch(state);
|
|
147
160
|
await mountEditor();
|
|
148
161
|
|
|
@@ -155,4 +168,47 @@ describe('useFileEditor revalidation', () => {
|
|
|
155
168
|
expect(state.reads).toBe(2);
|
|
156
169
|
expect(mounted?.content).toBe('edited by hand');
|
|
157
170
|
});
|
|
171
|
+
|
|
172
|
+
// A textarea's API value drops every CR, so a CRLF file reaches the buffer as
|
|
173
|
+
// LF — and multipart/form-data turns a bare LF back into CRLF, so the payload
|
|
174
|
+
// cannot carry the ending. The file's own ending therefore travels as its own
|
|
175
|
+
// field, and losing it rewrote every line of the file on a one-character edit.
|
|
176
|
+
test('a CRLF file is saved as CRLF after an edit', async () => {
|
|
177
|
+
const state: FsState = { reads: 0, writes: 0, sentEol: null, body: 'const a = 1;\r\nconst b = 2;\r\n' };
|
|
178
|
+
stubFetch(state);
|
|
179
|
+
await mountEditor();
|
|
180
|
+
|
|
181
|
+
expect(mounted?.content).toBe('const a = 1;\nconst b = 2;\n');
|
|
182
|
+
|
|
183
|
+
await act(async () => {
|
|
184
|
+
mounted?.onChange('const a = 1;\nconst c = 3;\n');
|
|
185
|
+
});
|
|
186
|
+
await settleEditor();
|
|
187
|
+
await act(async () => {
|
|
188
|
+
mounted?.saveNow();
|
|
189
|
+
});
|
|
190
|
+
await settleEditor();
|
|
191
|
+
|
|
192
|
+
expect(state.writes).toBe(1);
|
|
193
|
+
expect(state.body).toBe('const a = 1;\nconst c = 3;\n');
|
|
194
|
+
expect(state.sentEol).toBe('crlf');
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test('an LF file is saved as LF', async () => {
|
|
198
|
+
const state: FsState = { reads: 0, writes: 0, sentEol: null, body: DISK };
|
|
199
|
+
stubFetch(state);
|
|
200
|
+
await mountEditor();
|
|
201
|
+
|
|
202
|
+
await act(async () => {
|
|
203
|
+
mounted?.onChange('const a = 1;\n');
|
|
204
|
+
});
|
|
205
|
+
await settleEditor();
|
|
206
|
+
await act(async () => {
|
|
207
|
+
mounted?.saveNow();
|
|
208
|
+
});
|
|
209
|
+
await settleEditor();
|
|
210
|
+
|
|
211
|
+
expect(state.body).toBe('const a = 1;\n');
|
|
212
|
+
expect(state.sentEol).toBe('lf');
|
|
213
|
+
});
|
|
158
214
|
});
|
|
@@ -2,6 +2,8 @@ import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
|
|
|
2
2
|
import { getLanguageFromPath } from '@/shared/lib/code/syntax-highlight';
|
|
3
3
|
import { getImageMimeType } from '@/shared/lib/fs/file-kind';
|
|
4
4
|
import { buildFsRawUrl } from '@/shared/lib/fs/paths';
|
|
5
|
+
import { detectLineEnding, toDiskText, toLf, type LineEnding } from '@/shared/lib/code/line-endings';
|
|
6
|
+
import { retainKeys, saveEditorFile } from '@/client/hooks/editor/save-file';
|
|
5
7
|
import { useFileTransfer } from '@/client/hooks/editor/use-file-transfer';
|
|
6
8
|
|
|
7
9
|
export type FileEditorSaveStatus = 'idle' | 'saving' | 'saved' | 'error';
|
|
@@ -82,6 +84,12 @@ export function useFileEditor(
|
|
|
82
84
|
|
|
83
85
|
const [contents, setContents] = useState<Record<string, string>>({});
|
|
84
86
|
const [baselines, setBaselines] = useState<Record<string, string>>({});
|
|
87
|
+
/**
|
|
88
|
+
* The line ending each cached file arrived with. The buffer itself can only
|
|
89
|
+
* hold LF (a textarea strips CR), so the style has to live beside it or a
|
|
90
|
+
* CRLF file would be rewritten as LF by a one-character edit.
|
|
91
|
+
*/
|
|
92
|
+
const [lineEndings, setLineEndings] = useState<Record<string, LineEnding>>({});
|
|
85
93
|
const [isLoading, setIsLoading] = useState(false);
|
|
86
94
|
const [loadError, setLoadError] = useState<string | null>(null);
|
|
87
95
|
const [saveStatus, setSaveStatus] = useState<FileEditorSaveStatus>('idle');
|
|
@@ -89,7 +97,9 @@ export function useFileEditor(
|
|
|
89
97
|
const [revision, setRevision] = useState(0);
|
|
90
98
|
|
|
91
99
|
const key = target ? targetKey(target) : '';
|
|
92
|
-
|
|
100
|
+
// An inline `content` (attachment chip) can carry CRLF too, and the buffer it
|
|
101
|
+
// feeds is LF-only — normalizing here keeps `isDirty` comparing like with like.
|
|
102
|
+
const content = target ? (contents[key] ?? (target.content !== undefined ? toLf(target.content) : '')) : '';
|
|
93
103
|
const isDirty = content !== (baselines[key] ?? content);
|
|
94
104
|
|
|
95
105
|
// Extension-keyed, matching the server's own classification: an image tab
|
|
@@ -113,33 +123,34 @@ export function useFileEditor(
|
|
|
113
123
|
contentsRef.current = contents;
|
|
114
124
|
const baselinesRef = useRef(baselines);
|
|
115
125
|
baselinesRef.current = baselines;
|
|
126
|
+
// `persist` is a stable callback, so the ending it writes back is read from a
|
|
127
|
+
// live mirror rather than captured in its closure.
|
|
128
|
+
const lineEndingsRef = useRef(lineEndings);
|
|
129
|
+
lineEndingsRef.current = lineEndings;
|
|
116
130
|
|
|
117
131
|
const remember = useCallback((fileKey: string, text: string) => {
|
|
118
|
-
|
|
119
|
-
|
|
132
|
+
// The buffer holds LF only; the style the bytes arrived with is kept beside
|
|
133
|
+
// it so the write can put the file back the way it was found.
|
|
134
|
+
const ending = detectLineEnding(text);
|
|
135
|
+
const lf = toLf(text);
|
|
136
|
+
setContents((prev) => ({ ...prev, [fileKey]: lf }));
|
|
137
|
+
setBaselines((prev) => ({ ...prev, [fileKey]: lf }));
|
|
138
|
+
setLineEndings((prev) => ({ ...prev, [fileKey]: ending }));
|
|
120
139
|
}, []);
|
|
121
140
|
|
|
122
141
|
const persist = useCallback(
|
|
123
142
|
async (file: FileEditorTarget, text: string) => {
|
|
124
143
|
if (!file.path) return;
|
|
125
144
|
setSaveStatus('saving');
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
if (
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
if (res.ok && data?.success) {
|
|
136
|
-
setSaveStatus('saved');
|
|
137
|
-
setBaselines((prev) => ({ ...prev, [targetKey(file)]: text }));
|
|
138
|
-
onFileSaved?.();
|
|
139
|
-
} else {
|
|
140
|
-
setSaveStatus(reportSaveError ? 'error' : 'idle');
|
|
141
|
-
}
|
|
142
|
-
} catch {
|
|
145
|
+
const fileKey = targetKey(file);
|
|
146
|
+
// The buffer is LF; the file's own ending is applied by the server, so an
|
|
147
|
+
// untouched CRLF file stays byte-identical and an LF file stays LF.
|
|
148
|
+
const written = await saveEditorFile(file, text, lineEndingsRef.current[fileKey] ?? 'lf');
|
|
149
|
+
if (written) {
|
|
150
|
+
setSaveStatus('saved');
|
|
151
|
+
setBaselines((prev) => ({ ...prev, [fileKey]: text }));
|
|
152
|
+
onFileSaved?.();
|
|
153
|
+
} else {
|
|
143
154
|
setSaveStatus(reportSaveError ? 'error' : 'idle');
|
|
144
155
|
}
|
|
145
156
|
},
|
|
@@ -269,6 +280,11 @@ export function useFileEditor(
|
|
|
269
280
|
const { copied, copy, download } = useFileTransfer({
|
|
270
281
|
target,
|
|
271
282
|
content,
|
|
283
|
+
// A download is the file leaving the app, so it carries the file's own line
|
|
284
|
+
// endings — the LF buffer is an artifact of the textarea, not of the file.
|
|
285
|
+
// Copy stays the buffer: it is a text selection, and CRLF in a pasted
|
|
286
|
+
// snippet is noise every destination editor would normalize anyway.
|
|
287
|
+
downloadContent: target ? toDiskText(content, lineEndings[key] ?? 'lf') : content,
|
|
272
288
|
imageUrl,
|
|
273
289
|
downloadMimeType,
|
|
274
290
|
});
|
|
@@ -287,15 +303,8 @@ export function useFileEditor(
|
|
|
287
303
|
for (const loaded of Array.from(loadedRef.current)) {
|
|
288
304
|
if (!keep.has(loaded)) loadedRef.current.delete(loaded);
|
|
289
305
|
}
|
|
290
|
-
setContents((prev) =>
|
|
291
|
-
|
|
292
|
-
if (keys.every((k) => keep.has(k))) return prev;
|
|
293
|
-
const next: Record<string, string> = {};
|
|
294
|
-
for (const k of keys) {
|
|
295
|
-
if (keep.has(k)) next[k] = prev[k];
|
|
296
|
-
}
|
|
297
|
-
return next;
|
|
298
|
-
});
|
|
306
|
+
setContents((prev) => retainKeys(prev, keep));
|
|
307
|
+
setLineEndings((prev) => retainKeys(prev, keep));
|
|
299
308
|
}, []);
|
|
300
309
|
|
|
301
310
|
useEffect(() => {
|
|
@@ -25,6 +25,8 @@ interface UseFileTransferOptions {
|
|
|
25
25
|
target: FileTransferTarget | null;
|
|
26
26
|
/** Current text buffer for `target`; ignored for images. */
|
|
27
27
|
content: string;
|
|
28
|
+
/** Disk form of `content` (the file's own line endings), used by download. */
|
|
29
|
+
downloadContent: string;
|
|
28
30
|
/** Raw-byte URL of the active image, else null. */
|
|
29
31
|
imageUrl: string | null;
|
|
30
32
|
/** MIME type used for the text Blob. */
|
|
@@ -41,6 +43,7 @@ export interface UseFileTransferResult {
|
|
|
41
43
|
export function useFileTransfer({
|
|
42
44
|
target,
|
|
43
45
|
content,
|
|
46
|
+
downloadContent,
|
|
44
47
|
imageUrl,
|
|
45
48
|
downloadMimeType,
|
|
46
49
|
}: UseFileTransferOptions): UseFileTransferResult {
|
|
@@ -71,8 +74,8 @@ export function useFileTransfer({
|
|
|
71
74
|
void downloadUrl(imageUrl, target.name).catch(() => {});
|
|
72
75
|
return;
|
|
73
76
|
}
|
|
74
|
-
saveBlob(new Blob([
|
|
75
|
-
}, [target,
|
|
77
|
+
saveBlob(new Blob([downloadContent], { type: downloadMimeType }), target.name);
|
|
78
|
+
}, [target, downloadContent, downloadMimeType, imageUrl]);
|
|
76
79
|
|
|
77
80
|
return { copied, copy, download };
|
|
78
81
|
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The save branch's byte contract.
|
|
8
|
+
*
|
|
9
|
+
* `multipart/form-data` normalizes every bare LF in a field value to CRLF in
|
|
10
|
+
* transit — the HTML serializer does it on the way out and Bun's parser agrees
|
|
11
|
+
* on the way in — so `content` reaches this route CRLF-normalized no matter what
|
|
12
|
+
* the editor buffer held. The file's own ending therefore arrives as its own
|
|
13
|
+
* `eol` field, and the bytes written to disk are built from it. Without that
|
|
14
|
+
* step every save rewrote the whole file's line endings: an LF file came back
|
|
15
|
+
* CRLF, which `git diff` reports as every line changed.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { afterEach, describe, expect, test } from 'bun:test';
|
|
19
|
+
import fs from 'fs';
|
|
20
|
+
import path from 'path';
|
|
21
|
+
import { action } from '@/server/routes/fs/action';
|
|
22
|
+
|
|
23
|
+
/** Probe files live in the repo root — the one directory the root allow-list always accepts. */
|
|
24
|
+
const PROBE = path.join(process.cwd(), 'tmp-eol-probe.txt');
|
|
25
|
+
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
fs.rmSync(PROBE, { force: true });
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
async function save(content: string, eol?: string) {
|
|
31
|
+
const form = new FormData();
|
|
32
|
+
form.append('actionType', 'save');
|
|
33
|
+
form.append('root', process.cwd());
|
|
34
|
+
form.append('path', 'tmp-eol-probe.txt');
|
|
35
|
+
form.append('content', content);
|
|
36
|
+
if (eol) form.append('eol', eol);
|
|
37
|
+
const res = await action({ request: new Request('http://localhost/api/fs/action', { method: 'POST', body: form }) } as never);
|
|
38
|
+
expect(res.status).toBe(200);
|
|
39
|
+
return new Uint8Array(await Bun.file(PROBE).arrayBuffer());
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe('fs save line endings', () => {
|
|
43
|
+
test('an lf request writes lf, whatever multipart did in transit', async () => {
|
|
44
|
+
const bytes = await save('a\nb\n', 'lf');
|
|
45
|
+
expect([...bytes]).toEqual([0x61, 0x0a, 0x62, 0x0a]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('a crlf request writes crlf', async () => {
|
|
49
|
+
const bytes = await save('a\nb\n', 'crlf');
|
|
50
|
+
expect([...bytes]).toEqual([0x61, 0x0d, 0x0a, 0x62, 0x0d, 0x0a]);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('a payload that already carries crlf is not doubled', async () => {
|
|
54
|
+
const bytes = await save('a\r\nb\r\n', 'crlf');
|
|
55
|
+
expect([...bytes]).toEqual([0x61, 0x0d, 0x0a, 0x62, 0x0d, 0x0a]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// No `eol` means the route has no instruction, so the payload is written as
|
|
59
|
+
// multipart delivered it — which is CRLF, since the transport normalizes.
|
|
60
|
+
test('a payload without an eol field is written as delivered', async () => {
|
|
61
|
+
const bytes = await save('a\nb\n');
|
|
62
|
+
expect([...bytes]).toEqual([0x61, 0x0d, 0x0a, 0x62, 0x0d, 0x0a]);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -5,6 +5,7 @@ import path from 'path';
|
|
|
5
5
|
import { isMockMode } from '@/server/mock.server';
|
|
6
6
|
import { getDefaultFsRoot, resolveRoot, resolveWithinRoot } from '@/server/lib/fs/root';
|
|
7
7
|
import { runShell } from '@/server/lib/fs/shell';
|
|
8
|
+
import { toDiskText } from '@/shared/lib/code/line-endings';
|
|
8
9
|
export async function action({ request }: ActionFunctionArgs) {
|
|
9
10
|
const formData = await request.formData();
|
|
10
11
|
const actionType = formData.get('actionType') as string;
|
|
@@ -24,7 +25,15 @@ export async function action({ request }: ActionFunctionArgs) {
|
|
|
24
25
|
|
|
25
26
|
try {
|
|
26
27
|
if (actionType === 'save') {
|
|
27
|
-
const
|
|
28
|
+
const raw = (formData.get('content') as string) ?? '';
|
|
29
|
+
const eol = formData.get('eol');
|
|
30
|
+
// The bytes are built here rather than written verbatim: `multipart/
|
|
31
|
+
// form-data` normalizes every bare LF in a field value to CRLF in transit
|
|
32
|
+
// (the HTML serializer does it on the way out, Bun's parser agrees on the
|
|
33
|
+
// way in — same result either way), so an LF file would land on disk as
|
|
34
|
+
// CRLF and show up as a full-file diff. `eol` is the ending the client
|
|
35
|
+
// read the file with; without it the payload is written untouched.
|
|
36
|
+
const content = eol === 'crlf' || eol === 'lf' ? toDiskText(raw, eol) : raw;
|
|
28
37
|
await fs.promises.mkdir(path.dirname(fullPath), { recursive: true });
|
|
29
38
|
await Bun.write(fullPath, content);
|
|
30
39
|
return json({ success: true });
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, expect, test } from 'bun:test';
|
|
7
|
+
import { detectLineEnding, toDiskText, toLf } from '@/shared/lib/code/line-endings';
|
|
8
|
+
|
|
9
|
+
describe('line endings', () => {
|
|
10
|
+
test('the first terminator decides the style', () => {
|
|
11
|
+
expect(detectLineEnding('a\r\nb\n')).toBe('crlf');
|
|
12
|
+
expect(detectLineEnding('a\nb\r\n')).toBe('lf');
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test('a file with no terminator, or one using lone CR, reads as lf', () => {
|
|
16
|
+
expect(detectLineEnding('single line')).toBe('lf');
|
|
17
|
+
expect(detectLineEnding('\rleading')).toBe('lf');
|
|
18
|
+
expect(detectLineEnding('a\rb\r')).toBe('lf');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('the buffer form carries no CR', () => {
|
|
22
|
+
expect(toLf('a\r\nb\r\n')).toBe('a\nb\n');
|
|
23
|
+
expect(toLf('a\rb\r')).toBe('a\nb\n');
|
|
24
|
+
expect(toLf('a\nb\n')).toBe('a\nb\n');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('writing back restores the detected ending', () => {
|
|
28
|
+
expect(toDiskText('a\nb\n', 'crlf')).toBe('a\r\nb\r\n');
|
|
29
|
+
expect(toDiskText('a\nb\n', 'lf')).toBe('a\nb\n');
|
|
30
|
+
// A mixed buffer still normalizes before it is re-encoded.
|
|
31
|
+
expect(toDiskText('a\r\nb\n', 'crlf')).toBe('a\r\nb\r\n');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('an unedited CRLF round trip is byte-identical', () => {
|
|
35
|
+
const disk = 'const a = 1;\r\nconst b = 2;\r\n';
|
|
36
|
+
expect(toDiskText(toLf(disk), detectLineEnding(disk))).toBe(disk);
|
|
37
|
+
});
|
|
38
|
+
});
|