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.
Files changed (23) hide show
  1. package/README.md +12 -0
  2. package/dist/client/index.html +1 -1
  3. package/dist/client/static/js/async/{g.74ff36419f.js → g.cb81980498.js} +2 -2
  4. package/dist/client/static/js/async/{q.33d6779f98.js → q.22588845a9.js} +2 -2
  5. package/dist/client/static/js/async/{yx.af2936c368.js → xn1.83a7784fe1.js} +3 -3
  6. package/dist/client/static/js/{index.c05535dbde.js → index.e6a257402a.js} +2 -2
  7. package/package.json +1 -1
  8. package/src/client/components/workspace/diff-panel/SplitView.tsx +4 -1
  9. package/src/client/components/workspace/diff-panel/UnifiedView.tsx +70 -65
  10. package/src/client/components/workspace/diff-panel/index.tsx +8 -1
  11. package/src/client/components/workspace/editor/index.tsx +7 -0
  12. package/src/client/hooks/editor/save-file.ts +62 -0
  13. package/src/client/hooks/editor/use-file-editor.test.ts +61 -5
  14. package/src/client/hooks/editor/use-file-editor.ts +38 -29
  15. package/src/client/hooks/editor/use-file-transfer.ts +5 -2
  16. package/src/server/routes/fs/action.test.ts +64 -0
  17. package/src/server/routes/fs/action.ts +10 -1
  18. package/src/shared/lib/code/line-endings.test.ts +38 -0
  19. package/src/shared/lib/code/line-endings.ts +56 -0
  20. /package/dist/client/static/js/async/{g.74ff36419f.js.LICENSE.txt → g.cb81980498.js.LICENSE.txt} +0 -0
  21. /package/dist/client/static/js/async/{q.33d6779f98.js.LICENSE.txt → q.22588845a9.js.LICENSE.txt} +0 -0
  22. /package/dist/client/static/js/async/{yx.af2936c368.js.LICENSE.txt → xn1.83a7784fe1.js.LICENSE.txt} +0 -0
  23. /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.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
- <div className="w-max min-w-full min-w-[700px]">
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
- <table className="w-max min-w-full border-collapse">
42
- <tbody>
43
- {lines.map((line, idx) => {
44
- if (line.type === 'meta') {
45
- return (
46
- <tr key={idx} className="bg-canvas border-y border-ink/10 text-meta select-none">
47
- <td className="w-12 text-center py-1 px-2 text-[10px] opacity-60 border-r border-ink/10 font-mono">...</td>
48
- <td className="w-12 text-center py-1 px-2 text-[10px] opacity-60 border-r border-ink/10 font-mono">...</td>
49
- <td className="py-1 px-3 text-[11px] font-semibold text-meta font-mono tracking-tight">{line.text}</td>
50
- </tr>
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
- const isAdd = line.type === 'add';
55
- const isDel = line.type === 'del';
58
+ const isAdd = line.type === 'add';
59
+ const isDel = line.type === 'del';
56
60
 
57
- return (
58
- <tr
59
- key={idx}
60
- className={`transition-colors group ${
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/15 text-success font-semibold'
66
+ ? 'bg-success/10 hover:bg-success/15'
86
67
  : isDel
87
- ? 'bg-error/15 text-error/70'
88
- : 'bg-canvas/50 text-ink/40'
68
+ ? 'bg-error/10 hover:bg-error/15'
69
+ : 'hover:bg-ink/[0.03]'
89
70
  }`}
90
71
  >
91
- {line.newLineNumber || ''}
92
- </td>
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
- {/* Change prefix & code text */}
95
- <td className="py-0.5 px-3 whitespace-pre text-ink pr-6">
96
- <span
97
- className={`inline-block w-4 text-center select-none font-bold ${
98
- isAdd ? 'text-success' : isDel ? 'text-error' : 'opacity-0'
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
- {isAdd ? '+' : isDel ? '-' : ' '}
102
- </span>
103
- <span
104
- className="shiki"
105
- dangerouslySetInnerHTML={{
106
- __html: line.text ? htmlByIndex[idx] || '&nbsp;' : '&nbsp;',
107
- }}
108
- />
109
- </td>
110
- </tr>
111
- );
112
- })}
113
- </tbody>
114
- </table>
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] || '&nbsp;' : '&nbsp;',
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
- <div className={`flex flex-col h-full bg-paper select-text relative font-sans ${className}`}>
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 counts writes, without touching the network. */
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((init?.body as FormData).get('content'));
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
- const content = target ? (contents[key] ?? target.content ?? '') : '';
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
- setContents((prev) => ({ ...prev, [fileKey]: text }));
119
- setBaselines((prev) => ({ ...prev, [fileKey]: text }));
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 formData = new FormData();
127
- formData.append('actionType', 'save');
128
- formData.append('path', file.path);
129
- formData.append('content', text);
130
- if (file.root) formData.append('root', file.root);
131
- if (file.repo && file.repo !== '.') formData.append('repo', file.repo);
132
- try {
133
- const res = await fetch('/api/fs/action', { method: 'POST', body: formData });
134
- const data = await res.json().catch(() => null);
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
- const keys = Object.keys(prev);
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([content], { type: downloadMimeType }), target.name);
75
- }, [target, content, downloadMimeType, imageUrl]);
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 content = (formData.get('content') as string) ?? '';
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
+ });