xgen-dex-ide 1.60.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/README.md +42 -0
  2. package/dist/components/ActivityBar.d.ts +21 -0
  3. package/dist/components/ActivityBar.d.ts.map +1 -0
  4. package/dist/components/ActivityBar.js +14 -0
  5. package/dist/components/ActivityBar.js.map +1 -0
  6. package/dist/components/EditorArea.d.ts +5 -0
  7. package/dist/components/EditorArea.d.ts.map +1 -0
  8. package/dist/components/EditorArea.js +468 -0
  9. package/dist/components/EditorArea.js.map +1 -0
  10. package/dist/components/Explorer.d.ts +2 -0
  11. package/dist/components/Explorer.d.ts.map +1 -0
  12. package/dist/components/Explorer.js +349 -0
  13. package/dist/components/Explorer.js.map +1 -0
  14. package/dist/components/IdeView.d.ts +24 -0
  15. package/dist/components/IdeView.d.ts.map +1 -0
  16. package/dist/components/IdeView.js +359 -0
  17. package/dist/components/IdeView.js.map +1 -0
  18. package/dist/components/QuickOpen.d.ts +2 -0
  19. package/dist/components/QuickOpen.d.ts.map +1 -0
  20. package/dist/components/QuickOpen.js +204 -0
  21. package/dist/components/QuickOpen.js.map +1 -0
  22. package/dist/components/ScmView.d.ts +6 -0
  23. package/dist/components/ScmView.d.ts.map +1 -0
  24. package/dist/components/ScmView.js +283 -0
  25. package/dist/components/ScmView.js.map +1 -0
  26. package/dist/components/SearchView.d.ts +2 -0
  27. package/dist/components/SearchView.d.ts.map +1 -0
  28. package/dist/components/SearchView.js +97 -0
  29. package/dist/components/SearchView.js.map +1 -0
  30. package/dist/components/StatusBar.d.ts +2 -0
  31. package/dist/components/StatusBar.d.ts.map +1 -0
  32. package/dist/components/StatusBar.js +71 -0
  33. package/dist/components/StatusBar.js.map +1 -0
  34. package/dist/components/TerminalPanel.d.ts +6 -0
  35. package/dist/components/TerminalPanel.d.ts.map +1 -0
  36. package/dist/components/TerminalPanel.js +101 -0
  37. package/dist/components/TerminalPanel.js.map +1 -0
  38. package/dist/components/hooks.d.ts +25 -0
  39. package/dist/components/hooks.d.ts.map +1 -0
  40. package/dist/components/hooks.js +99 -0
  41. package/dist/components/hooks.js.map +1 -0
  42. package/dist/components/icons.d.ts +23 -0
  43. package/dist/components/icons.d.ts.map +1 -0
  44. package/dist/components/icons.js +123 -0
  45. package/dist/components/icons.js.map +1 -0
  46. package/dist/components/primitives.d.ts +57 -0
  47. package/dist/components/primitives.d.ts.map +1 -0
  48. package/dist/components/primitives.js +263 -0
  49. package/dist/components/primitives.js.map +1 -0
  50. package/dist/fuzzy.d.ts +17 -0
  51. package/dist/fuzzy.d.ts.map +1 -0
  52. package/dist/fuzzy.js +62 -0
  53. package/dist/fuzzy.js.map +1 -0
  54. package/dist/git-model.d.ts +104 -0
  55. package/dist/git-model.d.ts.map +1 -0
  56. package/dist/git-model.js +126 -0
  57. package/dist/git-model.js.map +1 -0
  58. package/dist/ide.css +2203 -0
  59. package/dist/index.d.ts +22 -0
  60. package/dist/index.d.ts.map +1 -0
  61. package/dist/index.js +19 -0
  62. package/dist/index.js.map +1 -0
  63. package/dist/keys.d.ts +33 -0
  64. package/dist/keys.d.ts.map +1 -0
  65. package/dist/keys.js +98 -0
  66. package/dist/keys.js.map +1 -0
  67. package/dist/paths.d.ts +14 -0
  68. package/dist/paths.d.ts.map +1 -0
  69. package/dist/paths.js +66 -0
  70. package/dist/paths.js.map +1 -0
  71. package/dist/store.d.ts +406 -0
  72. package/dist/store.d.ts.map +1 -0
  73. package/dist/store.js +1464 -0
  74. package/dist/store.js.map +1 -0
  75. package/dist/terminal.d.ts +60 -0
  76. package/dist/terminal.d.ts.map +1 -0
  77. package/dist/terminal.js +293 -0
  78. package/dist/terminal.js.map +1 -0
  79. package/dist/text.d.ts +26 -0
  80. package/dist/text.d.ts.map +1 -0
  81. package/dist/text.js +76 -0
  82. package/dist/text.js.map +1 -0
  83. package/dist/tree.d.ts +31 -0
  84. package/dist/tree.d.ts.map +1 -0
  85. package/dist/tree.js +105 -0
  86. package/dist/tree.js.map +1 -0
  87. package/dist/types.d.ts +216 -0
  88. package/dist/types.d.ts.map +1 -0
  89. package/dist/types.js +14 -0
  90. package/dist/types.js.map +1 -0
  91. package/package.json +42 -0
package/dist/store.js ADDED
@@ -0,0 +1,1464 @@
1
+ import { IdeError, } from './types.js';
2
+ import { basename, dirname, isWithin, join, normalize, uniqueCopyName } from './paths.js';
3
+ import { buildTree, ancestors } from './tree.js';
4
+ import { decodeText, encodeText, isImagePath, looksBinary } from './text.js';
5
+ import { gitErrorMessage, toWorkspacePath, } from './git-model.js';
6
+ export const DEFAULT_LAYOUT = {
7
+ sideView: 'explorer',
8
+ sideWidth: 260,
9
+ panelOpen: false,
10
+ panelHeight: 240,
11
+ panelMaximized: false,
12
+ chatOpen: true,
13
+ chatWidth: 400,
14
+ minimap: true,
15
+ wordWrap: false,
16
+ autoSave: false,
17
+ fontSize: 13,
18
+ };
19
+ const EMPTY_TREE = { path: '', name: '', isDir: true, children: [] };
20
+ export function tabIdFor(kind, path, diff) {
21
+ if (kind === 'diff' && diff?.disk)
22
+ return `disk:${path}`;
23
+ if (kind === 'diff' && diff)
24
+ return `diff:${diff.staged ? 'staged' : 'work'}:${diff.repo}:${diff.path}`;
25
+ return `${kind}:${path}`;
26
+ }
27
+ export function diskUri(monaco, workflowId, path) {
28
+ return monaco.Uri.from({ scheme: 'xide-disk', path: `/xgen/${workflowId}/${path}` });
29
+ }
30
+ let groupSeq = 0;
31
+ function newGroupId() {
32
+ groupSeq += 1;
33
+ return `g${Date.now().toString(36)}${groupSeq}`;
34
+ }
35
+ let dialogSeq = 0;
36
+ /** 다른 탭·다른 에이전트의 모델과 섞이지 않게 에이전트 id 를 주소에 넣는다. */
37
+ export function modelUri(monaco, workflowId, path) {
38
+ return monaco.Uri.from({ scheme: 'file', path: `/xgen/${workflowId}/${path}` });
39
+ }
40
+ export function originalUri(monaco, workflowId, diff) {
41
+ return monaco.Uri.from({
42
+ scheme: 'xide-git',
43
+ path: `/xgen/${workflowId}/${diff.repo ? diff.repo + '/' : ''}${diff.path}`,
44
+ query: diff.staged ? 'HEAD' : 'index',
45
+ });
46
+ }
47
+ export function stagedUri(monaco, workflowId, diff) {
48
+ return monaco.Uri.from({
49
+ scheme: 'xide-git',
50
+ path: `/xgen/${workflowId}/${diff.repo ? diff.repo + '/' : ''}${diff.path}`,
51
+ query: 'staged',
52
+ });
53
+ }
54
+ export class IdeStore {
55
+ host;
56
+ state;
57
+ listeners = new Set();
58
+ monaco = null;
59
+ monacoLoading = null;
60
+ loads = new Map();
61
+ /** 문서마다 "저장된 판" 의 버전 — 되돌리기로 저장 판에 돌아오면 깨끗해진다. */
62
+ savedVersion = new Map();
63
+ modelSubs = new Map();
64
+ autoSaveTimers = new Map();
65
+ closedTabs = [];
66
+ unsubscribeChanges = null;
67
+ pollTimer = null;
68
+ gitTimer = null;
69
+ filesTimer = null;
70
+ searchSeq = 0;
71
+ disposed = false;
72
+ /** 터미널 런타임(xterm·소켓)은 화면이 바뀌어도 산다 — terminal.ts 가 채운다. */
73
+ terminalRuntimes = new Map();
74
+ /** 편집기 묶음마다 탭별 화면 상태(스크롤·커서). */
75
+ viewStates = new Map();
76
+ /** 명령 표(명령 팔레트·단축키) — IdeView 가 채운다. */
77
+ commands = [];
78
+ /** 편집기 묶음 id → 그 묶음의 Monaco 편집기(찾기·줄 이동 같은 편집기 명령용). */
79
+ editors = new Map();
80
+ constructor(host) {
81
+ this.host = host;
82
+ const firstGroup = newGroupId();
83
+ this.state = {
84
+ ready: false,
85
+ readonly: false,
86
+ sessionError: null,
87
+ files: [],
88
+ tree: EMPTY_TREE,
89
+ filesLoaded: false,
90
+ filesError: null,
91
+ expanded: new Set(),
92
+ selected: null,
93
+ creating: null,
94
+ renaming: null,
95
+ clipboard: null,
96
+ groups: [{ id: firstGroup, tabs: [], activeId: null }],
97
+ activeGroup: firstGroup,
98
+ docs: {},
99
+ terminals: [],
100
+ activeTerminal: null,
101
+ layout: { ...DEFAULT_LAYOUT, ...this.readJson('layout', {}) },
102
+ git: {
103
+ repos: [],
104
+ statuses: {},
105
+ loading: false,
106
+ loaded: false,
107
+ error: null,
108
+ account: null,
109
+ busy: null,
110
+ message: {},
111
+ },
112
+ search: {
113
+ query: '',
114
+ replace: '',
115
+ showReplace: false,
116
+ showDetails: false,
117
+ regex: false,
118
+ caseSensitive: false,
119
+ word: false,
120
+ include: '',
121
+ exclude: '',
122
+ running: false,
123
+ result: null,
124
+ error: null,
125
+ collapsed: new Set(),
126
+ },
127
+ cursor: null,
128
+ notice: null,
129
+ dialog: null,
130
+ quickOpen: null,
131
+ monacoReady: false,
132
+ monacoError: null,
133
+ };
134
+ const expanded = this.readJson('expanded', []);
135
+ if (Array.isArray(expanded))
136
+ this.state.expanded = new Set(expanded);
137
+ }
138
+ // ── 구독 ──────────────────────────────────────────────────────────
139
+ getState = () => this.state;
140
+ subscribe = (fn) => {
141
+ this.listeners.add(fn);
142
+ return () => this.listeners.delete(fn);
143
+ };
144
+ set(patch) {
145
+ if (this.disposed)
146
+ return;
147
+ const next = typeof patch === 'function' ? patch(this.state) : patch;
148
+ this.state = { ...this.state, ...next };
149
+ for (const fn of [...this.listeners])
150
+ fn();
151
+ }
152
+ patchDoc(path, patch) {
153
+ this.set((s) => {
154
+ const cur = s.docs[path];
155
+ if (!cur)
156
+ return {};
157
+ return { docs: { ...s.docs, [path]: { ...cur, ...patch } } };
158
+ });
159
+ }
160
+ // ── 기억 ──────────────────────────────────────────────────────────
161
+ key(name) {
162
+ return `xide:${this.host.workflowId}:${name}`;
163
+ }
164
+ readJson(name, fallback) {
165
+ try {
166
+ const raw = this.host.storage?.get(this.key(name));
167
+ return raw ? JSON.parse(raw) : fallback;
168
+ }
169
+ catch {
170
+ return fallback;
171
+ }
172
+ }
173
+ writeJson(name, value) {
174
+ try {
175
+ this.host.storage?.set(this.key(name), JSON.stringify(value));
176
+ }
177
+ catch {
178
+ /* 기억 실패는 기능을 막지 않는다 */
179
+ }
180
+ }
181
+ persistTabs() {
182
+ const s = this.state;
183
+ this.writeJson('tabs', {
184
+ groups: s.groups.map((g) => ({
185
+ tabs: g.tabs.filter((t) => !t.preview || t.id === g.activeId).map((t) => ({ kind: t.kind, path: t.path, diff: t.diff })),
186
+ active: g.tabs.find((t) => t.id === g.activeId)?.path ?? null,
187
+ })),
188
+ activeIndex: s.groups.findIndex((g) => g.id === s.activeGroup),
189
+ });
190
+ }
191
+ // ── 시작·끝 ───────────────────────────────────────────────────────
192
+ started = false;
193
+ /** 화면이 여러 번 붙어도 시작은 한 번이다. */
194
+ startOnce() {
195
+ if (this.started)
196
+ return;
197
+ this.started = true;
198
+ void this.start();
199
+ }
200
+ async start() {
201
+ try {
202
+ const info = await this.host.session();
203
+ this.set({
204
+ ready: true,
205
+ readonly: info.readonly,
206
+ sessionError: null,
207
+ terminals: this.state.terminals.length
208
+ ? this.state.terminals
209
+ : info.terminals.map((t, i) => ({
210
+ id: t.id,
211
+ title: terminalTitle(t, i + 1),
212
+ status: 'idle',
213
+ cwd: t.cwd,
214
+ })),
215
+ });
216
+ if (!this.state.activeTerminal && this.state.terminals.length) {
217
+ this.set({ activeTerminal: this.state.terminals[0].id });
218
+ }
219
+ }
220
+ catch (err) {
221
+ this.set({ sessionError: errorMessage(err, '샌드박스에 연결하지 못했습니다') });
222
+ }
223
+ await this.refreshFiles();
224
+ this.restoreTabs();
225
+ void this.refreshGit();
226
+ if (this.host.subscribeChanges) {
227
+ this.unsubscribeChanges = this.host.subscribeChanges(() => this.notifyRemoteChange());
228
+ }
229
+ // 열어 둔 파일이 바깥(에이전트·터미널)에서 바뀌었는지 — 보이는 탭은 자주, 나머지는 드물게.
230
+ let tick = 0;
231
+ this.pollTimer = setInterval(() => {
232
+ tick += 1;
233
+ if (typeof document !== 'undefined' && document.hidden)
234
+ return;
235
+ void this.checkDisk(tick % 5 === 0 ? 'all' : 'visible');
236
+ if (!this.host.subscribeChanges && tick % 10 === 0)
237
+ void this.refreshFiles();
238
+ }, 3000);
239
+ // 노드(테스트)에서 이 타이머 하나가 프로세스를 붙잡지 않게 — 브라우저에는 없는 메서드다.
240
+ this.pollTimer.unref?.();
241
+ }
242
+ dispose() {
243
+ this.disposed = true;
244
+ this.unsubscribeChanges?.();
245
+ if (this.pollTimer)
246
+ clearInterval(this.pollTimer);
247
+ if (this.gitTimer)
248
+ clearTimeout(this.gitTimer);
249
+ if (this.filesTimer)
250
+ clearTimeout(this.filesTimer);
251
+ for (const t of this.autoSaveTimers.values())
252
+ clearTimeout(t);
253
+ for (const rt of this.terminalRuntimes.values())
254
+ rt.dispose();
255
+ this.terminalRuntimes.clear();
256
+ for (const sub of this.modelSubs.values())
257
+ sub.dispose();
258
+ this.modelSubs.clear();
259
+ if (this.monaco) {
260
+ for (const path of Object.keys(this.state.docs)) {
261
+ this.monaco.editor.getModel(modelUri(this.monaco, this.host.workflowId, path))?.dispose();
262
+ }
263
+ }
264
+ this.listeners.clear();
265
+ }
266
+ /** 바깥(에이전트의 턴·다른 기기)에서 바뀌었다 — 목록·열린 파일·소스 제어를 다시 본다. */
267
+ notifyRemoteChange() {
268
+ if (this.filesTimer)
269
+ clearTimeout(this.filesTimer);
270
+ this.filesTimer = setTimeout(() => {
271
+ void this.refreshFiles();
272
+ void this.checkDisk('all');
273
+ this.scheduleGit();
274
+ }, 400);
275
+ }
276
+ // ── 알림·대화 상자 ────────────────────────────────────────────────
277
+ notify(kind, message) {
278
+ this.set({ notice: { kind, message, at: Date.now() } });
279
+ this.host.notify?.(kind, message);
280
+ }
281
+ clearNotice() {
282
+ this.set({ notice: null });
283
+ }
284
+ ask(req) {
285
+ return new Promise((resolve) => {
286
+ dialogSeq += 1;
287
+ this.set({
288
+ dialog: {
289
+ ...req,
290
+ id: dialogSeq,
291
+ resolve: (r) => {
292
+ this.set({ dialog: null });
293
+ resolve(r);
294
+ },
295
+ },
296
+ });
297
+ });
298
+ }
299
+ async confirm(title, message, ok, danger = false) {
300
+ const r = await this.ask({
301
+ title,
302
+ message,
303
+ buttons: [
304
+ { id: 'ok', label: ok, primary: !danger, danger },
305
+ { id: 'cancel', label: '취소' },
306
+ ],
307
+ });
308
+ return r.button === 'ok';
309
+ }
310
+ async prompt(title, options = {}) {
311
+ const r = await this.ask({
312
+ title,
313
+ buttons: [
314
+ { id: 'ok', label: options.ok ?? '확인', primary: true },
315
+ { id: 'cancel', label: '취소' },
316
+ ],
317
+ input: { value: options.value ?? '', placeholder: options.placeholder, select: options.select },
318
+ validate: options.validate,
319
+ });
320
+ return r.button === 'ok' ? r.value : null;
321
+ }
322
+ // ── 화면 배치 ─────────────────────────────────────────────────────
323
+ setLayout(patch) {
324
+ this.set((s) => ({ layout: { ...s.layout, ...patch } }));
325
+ this.writeJson('layout', this.state.layout);
326
+ }
327
+ toggleSideView(view) {
328
+ const cur = this.state.layout.sideView;
329
+ this.setLayout({ sideView: cur === view ? null : view });
330
+ }
331
+ showSideView(view) {
332
+ this.setLayout({ sideView: view });
333
+ }
334
+ openQuickOpen(mode, initial = '') {
335
+ this.set({ quickOpen: { mode, initial } });
336
+ }
337
+ closeQuickOpen() {
338
+ this.set({ quickOpen: null });
339
+ }
340
+ setCursor(cursor) {
341
+ const cur = this.state.cursor;
342
+ if (cur && cursor && cur.line === cursor.line && cur.col === cursor.col && cur.selected === cursor.selected)
343
+ return;
344
+ this.set({ cursor });
345
+ }
346
+ // ── 파일 목록 ─────────────────────────────────────────────────────
347
+ async refreshFiles() {
348
+ try {
349
+ const files = await this.host.listFiles();
350
+ this.set({ files, tree: buildTree(files), filesLoaded: true, filesError: null });
351
+ }
352
+ catch (err) {
353
+ this.set({ filesLoaded: true, filesError: errorMessage(err, '파일 목록을 불러오지 못했습니다') });
354
+ }
355
+ }
356
+ isDir(path) {
357
+ if (!path)
358
+ return true;
359
+ const p = normalize(path);
360
+ return this.state.files.some((f) => (f.isDir && f.path === p) || f.path.startsWith(p + '/'));
361
+ }
362
+ exists(path) {
363
+ const p = normalize(path);
364
+ return this.state.files.some((f) => f.path === p || f.path.startsWith(p + '/'));
365
+ }
366
+ setExpanded(path, open) {
367
+ this.set((s) => {
368
+ const next = new Set(s.expanded);
369
+ if (open)
370
+ next.add(path);
371
+ else
372
+ next.delete(path);
373
+ return { expanded: next };
374
+ });
375
+ this.writeJson('expanded', [...this.state.expanded]);
376
+ }
377
+ collapseAll() {
378
+ this.set({ expanded: new Set() });
379
+ this.writeJson('expanded', []);
380
+ }
381
+ reveal(path) {
382
+ const want = ancestors(path);
383
+ if (want.every((d) => this.state.expanded.has(d)) && this.state.selected === path)
384
+ return;
385
+ this.set((s) => ({ expanded: new Set([...s.expanded, ...want]), selected: path }));
386
+ this.writeJson('expanded', [...this.state.expanded]);
387
+ }
388
+ select(path) {
389
+ this.set({ selected: path });
390
+ }
391
+ startCreate(parent, kind) {
392
+ if (this.state.readonly)
393
+ return;
394
+ if (parent)
395
+ this.setExpanded(parent, true);
396
+ this.set({ creating: { parent, kind }, renaming: null });
397
+ this.showSideView('explorer');
398
+ }
399
+ cancelCreate() {
400
+ this.set({ creating: null });
401
+ }
402
+ startRename(path) {
403
+ if (this.state.readonly)
404
+ return;
405
+ this.set({ renaming: path, creating: null });
406
+ }
407
+ cancelRename() {
408
+ this.set({ renaming: null });
409
+ }
410
+ // ── Monaco ────────────────────────────────────────────────────────
411
+ loadMonaco() {
412
+ if (this.monaco)
413
+ return Promise.resolve(this.monaco);
414
+ if (!this.monacoLoading) {
415
+ this.monacoLoading = this.host.loadMonaco().then((m) => {
416
+ this.monaco = m;
417
+ ignoreMonacoCancellation();
418
+ this.set({ monacoReady: true, monacoError: null });
419
+ return m;
420
+ }, (err) => {
421
+ this.monacoLoading = null;
422
+ this.set({ monacoError: errorMessage(err, '편집기를 불러오지 못했습니다') });
423
+ throw err;
424
+ });
425
+ }
426
+ return this.monacoLoading;
427
+ }
428
+ /** 저장하지 않은 파일(호스트가 탭·창을 닫기 전에 묻는 데 쓴다). */
429
+ unsavedPaths() {
430
+ return Object.values(this.state.docs)
431
+ .filter((d) => d.dirty)
432
+ .map((d) => d.path);
433
+ }
434
+ getMonaco() {
435
+ return this.monaco;
436
+ }
437
+ getModel(path) {
438
+ if (!this.monaco)
439
+ return null;
440
+ return this.monaco.editor.getModel(modelUri(this.monaco, this.host.workflowId, path));
441
+ }
442
+ // ── 문서 ──────────────────────────────────────────────────────────
443
+ /** 문서를 준비한다(이미 있으면 그대로). 텍스트면 Monaco 모델까지 만든다. */
444
+ ensureDoc(path, opts = {}) {
445
+ const p = normalize(path);
446
+ const cur = this.state.docs[p];
447
+ if (cur && cur.status !== 'error' && cur.status !== 'missing' && !(opts.forceText && cur.status === 'binary')) {
448
+ return this.loads.get(p) ?? Promise.resolve();
449
+ }
450
+ const job = this.loadDoc(p, opts).finally(() => this.loads.delete(p));
451
+ this.loads.set(p, job);
452
+ return job;
453
+ }
454
+ async loadDoc(path, opts) {
455
+ this.set((s) => ({
456
+ docs: {
457
+ ...s.docs,
458
+ [path]: {
459
+ path,
460
+ status: 'loading',
461
+ sha: '',
462
+ size: 0,
463
+ dirty: false,
464
+ eol: 'LF',
465
+ bom: false,
466
+ saving: false,
467
+ diskChanged: false,
468
+ },
469
+ },
470
+ }));
471
+ let read;
472
+ try {
473
+ [read] = await Promise.all([this.host.readFile(path), this.loadMonaco()]);
474
+ }
475
+ catch (err) {
476
+ if (err instanceof IdeError && err.code === 'too_large') {
477
+ this.patchDoc(path, { status: 'too_large', message: err.message, size: Number(err.detail?.size ?? 0) });
478
+ }
479
+ else if (err instanceof IdeError && err.code === 'not_found') {
480
+ this.patchDoc(path, { status: 'missing', message: '파일이 없습니다' });
481
+ }
482
+ else {
483
+ this.patchDoc(path, { status: 'error', message: errorMessage(err, '파일을 열지 못했습니다') });
484
+ }
485
+ return;
486
+ }
487
+ if (!opts.forceText && looksBinary(read.bytes)) {
488
+ this.patchDoc(path, { status: 'binary', sha: read.sha, size: read.size });
489
+ return;
490
+ }
491
+ const decoded = decodeText(read.bytes);
492
+ const monaco = this.monaco;
493
+ const uri = modelUri(monaco, this.host.workflowId, path);
494
+ let model = monaco.editor.getModel(uri);
495
+ if (model)
496
+ model.setValue(decoded.text);
497
+ else
498
+ model = monaco.editor.createModel(decoded.text, undefined, uri);
499
+ model.setEOL(decoded.eol === 'CRLF' ? monaco.editor.EndOfLineSequence.CRLF : monaco.editor.EndOfLineSequence.LF);
500
+ this.savedVersion.set(path, model.getAlternativeVersionId());
501
+ this.modelSubs.get(path)?.dispose();
502
+ this.modelSubs.set(path, model.onDidChangeContent(() => this.onModelChange(path)));
503
+ this.patchDoc(path, {
504
+ status: 'ready',
505
+ sha: read.sha,
506
+ size: read.size,
507
+ eol: decoded.eol,
508
+ bom: decoded.bom,
509
+ dirty: false,
510
+ diskChanged: false,
511
+ message: undefined,
512
+ });
513
+ }
514
+ onModelChange(path) {
515
+ const model = this.getModel(path);
516
+ const doc = this.state.docs[path];
517
+ if (!model || !doc)
518
+ return;
519
+ const dirty = model.getAlternativeVersionId() !== this.savedVersion.get(path);
520
+ if (dirty !== doc.dirty)
521
+ this.patchDoc(path, { dirty });
522
+ if (dirty)
523
+ this.pinPreviewTabsOf(path);
524
+ if (dirty && this.state.layout.autoSave) {
525
+ clearTimeout(this.autoSaveTimers.get(path));
526
+ this.autoSaveTimers.set(path, setTimeout(() => {
527
+ this.autoSaveTimers.delete(path);
528
+ if (this.state.docs[path]?.dirty && !this.state.docs[path]?.diskChanged)
529
+ void this.save(path, { quiet: true });
530
+ }, 1000));
531
+ }
532
+ }
533
+ /** 문서를 쓰는 탭이 하나도 없으면 버린다(모델까지). */
534
+ releaseDocIfUnused(path) {
535
+ const used = this.state.groups.some((g) => g.tabs.some((t) => t.path === path && (t.kind === 'file' || (t.kind === 'diff' && !t.diff?.staged))));
536
+ if (used)
537
+ return;
538
+ this.modelSubs.get(path)?.dispose();
539
+ this.modelSubs.delete(path);
540
+ this.savedVersion.delete(path);
541
+ clearTimeout(this.autoSaveTimers.get(path));
542
+ this.autoSaveTimers.delete(path);
543
+ this.getModel(path)?.dispose();
544
+ this.set((s) => {
545
+ const docs = { ...s.docs };
546
+ delete docs[path];
547
+ return { docs };
548
+ });
549
+ }
550
+ async save(path, opts = {}) {
551
+ const doc = this.state.docs[path];
552
+ const model = this.getModel(path);
553
+ if (!doc || !model || doc.status !== 'ready' || this.state.readonly)
554
+ return false;
555
+ if (doc.saving)
556
+ return false;
557
+ const version = model.getAlternativeVersionId();
558
+ const bytes = encodeText(model.getValue(), doc.eol, doc.bom);
559
+ this.patchDoc(path, { saving: true });
560
+ try {
561
+ const out = await this.host.saveFile(path, bytes, opts.force ? null : doc.sha);
562
+ this.savedVersion.set(path, version);
563
+ this.patchDoc(path, {
564
+ saving: false,
565
+ sha: out.sha,
566
+ size: bytes.length,
567
+ dirty: model.getAlternativeVersionId() !== version,
568
+ diskChanged: false,
569
+ });
570
+ if (!this.exists(path))
571
+ void this.refreshFiles();
572
+ this.scheduleGit();
573
+ if (out.conflicts?.length) {
574
+ this.notify('warning', '다른 곳에서 같은 파일을 고쳐 두 판을 모두 남겼습니다. 탐색기에서 .conflict 파일을 확인하세요.');
575
+ }
576
+ else if (!opts.quiet) {
577
+ this.set({ notice: { kind: 'success', message: `${basename(path)} 저장됨`, at: Date.now() } });
578
+ }
579
+ return true;
580
+ }
581
+ catch (err) {
582
+ this.patchDoc(path, { saving: false });
583
+ if (err instanceof IdeError && err.code === 'changed') {
584
+ this.patchDoc(path, { diskChanged: true });
585
+ return this.resolveSaveConflict(path);
586
+ }
587
+ this.notify('error', errorMessage(err, `${basename(path)} 을(를) 저장하지 못했습니다`));
588
+ return false;
589
+ }
590
+ }
591
+ /** 저장했더니 디스크 판이 그사이 바뀌었다 — 비교·덮어쓰기·되돌리기 중 고른다. */
592
+ async resolveSaveConflict(path) {
593
+ const r = await this.ask({
594
+ title: `${basename(path)} 이(가) 그사이 바뀌었습니다`,
595
+ message: '에이전트나 터미널이 이 파일을 먼저 고쳤습니다. 어떻게 할까요?',
596
+ buttons: [
597
+ { id: 'compare', label: '비교', primary: true },
598
+ { id: 'overwrite', label: '내 판으로 덮어쓰기', danger: true },
599
+ { id: 'revert', label: '바뀐 판 불러오기' },
600
+ { id: 'cancel', label: '취소' },
601
+ ],
602
+ });
603
+ if (r.button === 'overwrite')
604
+ return this.save(path, { force: true });
605
+ if (r.button === 'revert') {
606
+ await this.reloadFromDisk(path, { discard: true });
607
+ return false;
608
+ }
609
+ if (r.button === 'compare')
610
+ await this.openDiskCompare(path);
611
+ return false;
612
+ }
613
+ /** 디스크 판(왼쪽)과 내 버퍼(오른쪽)를 나란히 본다. */
614
+ async openDiskCompare(path) {
615
+ const monaco = await this.loadMonaco();
616
+ let read;
617
+ try {
618
+ read = await this.host.readFile(path);
619
+ }
620
+ catch (err) {
621
+ this.notify('error', errorMessage(err, '디스크 판을 읽지 못했습니다'));
622
+ return;
623
+ }
624
+ const uri = diskUri(monaco, this.host.workflowId, path);
625
+ const text = decodeText(read.bytes).text;
626
+ const existing = monaco.editor.getModel(uri);
627
+ if (existing)
628
+ existing.setValue(text);
629
+ else
630
+ monaco.editor.createModel(text, undefined, uri);
631
+ const diff = { repo: '', path, staged: false, status: 'modified', disk: true };
632
+ this.openTab({ id: tabIdFor('diff', path, diff), kind: 'diff', path, preview: false, diff });
633
+ }
634
+ /** 줄 끝을 바꿨다(상태 표시줄) — 다음 저장이 그 줄 끝으로 쓴다. */
635
+ setDocEol(path, eol) {
636
+ this.patchDoc(path, { eol });
637
+ this.onModelChange(path);
638
+ }
639
+ async saveAll() {
640
+ for (const doc of Object.values(this.state.docs)) {
641
+ if (doc.dirty && doc.status === 'ready')
642
+ await this.save(doc.path, { quiet: true });
643
+ }
644
+ }
645
+ /** 디스크에서 다시 읽는다. `discard` 면 고친 것을 버린다(되돌리기 기록은 남긴다). */
646
+ async reloadFromDisk(path, opts = {}) {
647
+ const doc = this.state.docs[path];
648
+ const model = this.getModel(path);
649
+ if (!doc || !model)
650
+ return;
651
+ if (doc.dirty && !opts.discard)
652
+ return;
653
+ let read;
654
+ try {
655
+ read = await this.host.readFile(path);
656
+ }
657
+ catch (err) {
658
+ if (err instanceof IdeError && err.code === 'not_found') {
659
+ this.patchDoc(path, { diskChanged: true, message: '디스크에서 지워졌습니다' });
660
+ }
661
+ return;
662
+ }
663
+ const decoded = decodeText(read.bytes);
664
+ if (model.getValue() !== decoded.text) {
665
+ model.pushStackElement();
666
+ model.pushEditOperations([], [{ range: model.getFullModelRange(), text: decoded.text }], () => null);
667
+ model.pushStackElement();
668
+ }
669
+ this.savedVersion.set(path, model.getAlternativeVersionId());
670
+ this.patchDoc(path, {
671
+ sha: read.sha,
672
+ size: read.size,
673
+ eol: decoded.eol,
674
+ bom: decoded.bom,
675
+ dirty: false,
676
+ diskChanged: false,
677
+ message: undefined,
678
+ });
679
+ }
680
+ /** 열어 둔 파일의 디스크 판을 본다 — 바뀌었으면 깨끗한 버퍼는 조용히 다시 읽는다. */
681
+ async checkDisk(which) {
682
+ const docs = Object.values(this.state.docs).filter((d) => d.status === 'ready' && d.sha);
683
+ const visible = new Set(this.state.groups.map((g) => g.tabs.find((t) => t.id === g.activeId)?.path).filter(Boolean));
684
+ const paths = docs.map((d) => d.path).filter((p) => which === 'all' || visible.has(p));
685
+ if (!paths.length)
686
+ return;
687
+ let stats;
688
+ try {
689
+ stats = await this.host.stat(paths);
690
+ }
691
+ catch {
692
+ return;
693
+ }
694
+ for (const path of paths) {
695
+ const st = stats[path];
696
+ const doc = this.state.docs[path];
697
+ if (!st || !doc || doc.saving)
698
+ continue;
699
+ if (st.kind === 'file' && st.sha && st.sha !== doc.sha) {
700
+ if (doc.dirty) {
701
+ if (!doc.diskChanged)
702
+ this.patchDoc(path, { diskChanged: true });
703
+ }
704
+ else {
705
+ await this.reloadFromDisk(path);
706
+ }
707
+ }
708
+ else if (st.kind === 'missing' && !doc.diskChanged) {
709
+ this.patchDoc(path, { diskChanged: true, message: '디스크에서 지워졌습니다' });
710
+ }
711
+ }
712
+ }
713
+ // ── 탭·편집기 묶음 ────────────────────────────────────────────────
714
+ activeGroup() {
715
+ return this.state.groups.find((g) => g.id === this.state.activeGroup) ?? this.state.groups[0];
716
+ }
717
+ activeTab() {
718
+ const g = this.activeGroup();
719
+ return g.tabs.find((t) => t.id === g.activeId) ?? null;
720
+ }
721
+ setActiveGroup(id) {
722
+ if (this.state.activeGroup !== id && this.state.groups.some((g) => g.id === id))
723
+ this.set({ activeGroup: id });
724
+ }
725
+ /** 탭을 연다. 미리보기 탭은 같은 묶음의 기존 미리보기 자리를 바꿔 쓴다. */
726
+ openTab(tab, opts = {}) {
727
+ const groupId = opts.groupId ?? this.state.activeGroup;
728
+ this.set((s) => {
729
+ const groups = s.groups.map((g) => {
730
+ if (g.id !== groupId)
731
+ return g;
732
+ const existing = g.tabs.find((t) => t.id === tab.id);
733
+ if (existing) {
734
+ const tabs = tab.preview ? g.tabs : g.tabs.map((t) => (t.id === tab.id ? { ...t, preview: false } : t));
735
+ return { ...g, tabs, activeId: opts.background ? g.activeId : tab.id };
736
+ }
737
+ let tabs = g.tabs;
738
+ if (tab.preview) {
739
+ const idx = tabs.findIndex((t) => t.preview);
740
+ if (idx >= 0) {
741
+ const replaced = tabs[idx];
742
+ tabs = [...tabs.slice(0, idx), tab, ...tabs.slice(idx + 1)];
743
+ queueMicrotask(() => this.releaseDocIfUnused(replaced.path));
744
+ return { ...g, tabs, activeId: opts.background ? g.activeId : tab.id };
745
+ }
746
+ }
747
+ const at = g.activeId ? tabs.findIndex((t) => t.id === g.activeId) + 1 : tabs.length;
748
+ tabs = [...tabs.slice(0, at), tab, ...tabs.slice(at)];
749
+ return { ...g, tabs, activeId: opts.background ? g.activeId : tab.id };
750
+ });
751
+ return { groups, activeGroup: groupId };
752
+ });
753
+ this.persistTabs();
754
+ }
755
+ /** 파일을 연다 — 그림은 그림으로, 나머지는 편집기로. */
756
+ async openFile(path, opts = {}) {
757
+ const p = normalize(path);
758
+ if (!p)
759
+ return;
760
+ const kind = isImagePath(p) && !opts.forceText ? 'image' : 'file';
761
+ this.openTab({ id: tabIdFor(kind, p), kind, path: p, preview: opts.preview ?? false }, { groupId: opts.groupId });
762
+ this.reveal(p);
763
+ if (kind === 'file') {
764
+ await this.ensureDoc(p, { forceText: opts.forceText });
765
+ if (opts.line)
766
+ this.pendingReveal = { path: p, line: opts.line, col: opts.col ?? 1, length: opts.length ?? 0 };
767
+ this.set({});
768
+ }
769
+ }
770
+ /** 줄로 이동해야 하는 문서 — 편집기가 모델을 붙인 뒤 소비한다. */
771
+ pendingReveal = null;
772
+ openDiff(diff, opts = {}) {
773
+ const path = toWorkspacePath(diff.repo, diff.path);
774
+ this.openTab({ id: tabIdFor('diff', path, diff), kind: 'diff', path, preview: opts.preview ?? true, diff });
775
+ if (!diff.staged && diff.status !== 'deleted')
776
+ void this.ensureDoc(path);
777
+ }
778
+ pinTab(groupId, tabId) {
779
+ this.set((s) => ({
780
+ groups: s.groups.map((g) => g.id === groupId ? { ...g, tabs: g.tabs.map((t) => (t.id === tabId ? { ...t, preview: false } : t)) } : g),
781
+ }));
782
+ this.persistTabs();
783
+ }
784
+ pinPreviewTabsOf(path) {
785
+ if (!this.state.groups.some((g) => g.tabs.some((t) => t.path === path && t.preview && t.kind === 'file')))
786
+ return;
787
+ this.set((s) => ({
788
+ groups: s.groups.map((g) => ({
789
+ ...g,
790
+ tabs: g.tabs.map((t) => (t.path === path && t.preview && t.kind === 'file' ? { ...t, preview: false } : t)),
791
+ })),
792
+ }));
793
+ this.persistTabs();
794
+ }
795
+ activateTab(groupId, tabId) {
796
+ this.set((s) => ({
797
+ activeGroup: groupId,
798
+ groups: s.groups.map((g) => (g.id === groupId ? { ...g, activeId: tabId } : g)),
799
+ }));
800
+ const tab = this.state.groups.find((g) => g.id === groupId)?.tabs.find((t) => t.id === tabId);
801
+ if (tab && tab.kind !== 'diff')
802
+ this.reveal(tab.path);
803
+ this.persistTabs();
804
+ }
805
+ /** 탭을 닫는다. 고친 채면 먼저 묻는다(저장·저장 안 함·취소). */
806
+ async closeTab(groupId, tabId, opts = {}) {
807
+ const group = this.state.groups.find((g) => g.id === groupId);
808
+ const tab = group?.tabs.find((t) => t.id === tabId);
809
+ if (!group || !tab)
810
+ return true;
811
+ const doc = this.state.docs[tab.path];
812
+ // 이 탭이 그 문서를 보여 주는 **마지막** 탭인가(다른 묶음의 같은 파일·작업 트리 비교도 센다).
813
+ const viewsDoc = (t) => t.path === tab.path && (t.kind === 'file' || (t.kind === 'diff' && !t.diff?.staged));
814
+ const others = this.state.groups.reduce((n, g) => n + g.tabs.filter((t) => viewsDoc(t) && !(g.id === groupId && t.id === tabId)).length, 0);
815
+ const lastView = viewsDoc(tab) && others === 0;
816
+ if (!opts.force && lastView && doc?.dirty) {
817
+ const r = await this.ask({
818
+ title: `${basename(tab.path)} 의 변경 내용을 저장할까요?`,
819
+ message: '저장하지 않으면 변경 내용이 사라집니다.',
820
+ buttons: [
821
+ { id: 'save', label: '저장', primary: true },
822
+ { id: 'discard', label: '저장 안 함', danger: true },
823
+ { id: 'cancel', label: '취소' },
824
+ ],
825
+ });
826
+ if (r.button === 'cancel')
827
+ return false;
828
+ if (r.button === 'save' && !(await this.save(tab.path)))
829
+ return false;
830
+ }
831
+ this.set((s) => {
832
+ let groups = s.groups.map((g) => {
833
+ if (g.id !== groupId)
834
+ return g;
835
+ const idx = g.tabs.findIndex((t) => t.id === tabId);
836
+ const tabs = g.tabs.filter((t) => t.id !== tabId);
837
+ let activeId = g.activeId;
838
+ if (activeId === tabId)
839
+ activeId = tabs[Math.min(idx, tabs.length - 1)]?.id ?? null;
840
+ return { ...g, tabs, activeId };
841
+ });
842
+ let activeGroup = s.activeGroup;
843
+ // 빈 묶음은 접는다(하나는 남긴다).
844
+ if (groups.length > 1) {
845
+ const emptied = groups.find((g) => g.id === groupId && g.tabs.length === 0);
846
+ if (emptied) {
847
+ const i = groups.indexOf(emptied);
848
+ groups = groups.filter((g) => g !== emptied);
849
+ if (activeGroup === groupId)
850
+ activeGroup = groups[Math.max(0, i - 1)].id;
851
+ this.editors.delete(groupId);
852
+ }
853
+ }
854
+ return { groups, activeGroup };
855
+ });
856
+ this.closedTabs.push(tab);
857
+ if (this.closedTabs.length > 30)
858
+ this.closedTabs.shift();
859
+ this.viewStates.delete(`${groupId}:${tabId}`);
860
+ this.releaseDocIfUnused(tab.path);
861
+ this.persistTabs();
862
+ return true;
863
+ }
864
+ async closeTabs(groupId, which, pivot) {
865
+ const group = this.state.groups.find((g) => g.id === groupId);
866
+ if (!group)
867
+ return;
868
+ const idx = pivot ? group.tabs.findIndex((t) => t.id === pivot) : -1;
869
+ const targets = group.tabs.filter((t, i) => {
870
+ if (which === 'others')
871
+ return t.id !== pivot;
872
+ if (which === 'right')
873
+ return i > idx;
874
+ if (which === 'saved')
875
+ return !this.state.docs[t.path]?.dirty;
876
+ return true;
877
+ });
878
+ for (const t of targets) {
879
+ if (!(await this.closeTab(groupId, t.id)))
880
+ break;
881
+ }
882
+ }
883
+ async closeAllGroups() {
884
+ for (const g of [...this.state.groups])
885
+ await this.closeTabs(g.id, 'all');
886
+ }
887
+ reopenClosedTab() {
888
+ const tab = this.closedTabs.pop();
889
+ if (!tab)
890
+ return;
891
+ if (tab.kind === 'diff' && tab.diff)
892
+ this.openDiff(tab.diff, { preview: false });
893
+ else
894
+ void this.openFile(tab.path, { preview: false });
895
+ }
896
+ cycleTab(delta) {
897
+ const g = this.activeGroup();
898
+ if (!g.tabs.length)
899
+ return;
900
+ const i = g.tabs.findIndex((t) => t.id === g.activeId);
901
+ const next = g.tabs[(i + delta + g.tabs.length) % g.tabs.length];
902
+ this.activateTab(g.id, next.id);
903
+ }
904
+ moveTab(fromGroup, tabId, toGroup, index) {
905
+ const src = this.state.groups.find((g) => g.id === fromGroup);
906
+ const tab = src?.tabs.find((t) => t.id === tabId);
907
+ if (!src || !tab)
908
+ return;
909
+ this.set((s) => {
910
+ let groups = s.groups.map((g) => {
911
+ if (g.id === fromGroup && g.id === toGroup) {
912
+ const tabs = g.tabs.filter((t) => t.id !== tabId);
913
+ const at = Math.max(0, Math.min(index, tabs.length));
914
+ return { ...g, tabs: [...tabs.slice(0, at), tab, ...tabs.slice(at)], activeId: tabId };
915
+ }
916
+ if (g.id === fromGroup) {
917
+ const tabs = g.tabs.filter((t) => t.id !== tabId);
918
+ const activeId = g.activeId === tabId ? tabs[0]?.id ?? null : g.activeId;
919
+ return { ...g, tabs, activeId };
920
+ }
921
+ if (g.id === toGroup) {
922
+ const tabs = g.tabs.filter((t) => t.id !== tabId);
923
+ const at = Math.max(0, Math.min(index, tabs.length));
924
+ return { ...g, tabs: [...tabs.slice(0, at), tab, ...tabs.slice(at)], activeId: tabId };
925
+ }
926
+ return g;
927
+ });
928
+ if (groups.length > 1)
929
+ groups = groups.filter((g) => g.tabs.length > 0);
930
+ return { groups, activeGroup: toGroup };
931
+ });
932
+ this.persistTabs();
933
+ }
934
+ /**
935
+ * 편집기를 오른쪽으로 나눈다(최대 3묶음). 기본은 지금 탭을 새 묶음에도 연다(편집기의
936
+ * "나누기"). `empty` 면 빈 묶음을 만든다("옆에 열기"). 새(또는 오른쪽) 묶음 id 를 돌려준다.
937
+ */
938
+ splitRight(opts = {}) {
939
+ const g = this.activeGroup();
940
+ const tab = opts.empty ? undefined : g.tabs.find((t) => t.id === (opts.tabId ?? g.activeId));
941
+ const idx = this.state.groups.indexOf(g);
942
+ if (this.state.groups.length >= 3) {
943
+ const next = this.state.groups[Math.min(idx + 1, this.state.groups.length - 1)];
944
+ if (tab && next.id !== g.id)
945
+ this.openTab({ ...tab, preview: false }, { groupId: next.id });
946
+ return next.id;
947
+ }
948
+ const id = newGroupId();
949
+ this.set((s) => {
950
+ const groups = [...s.groups];
951
+ groups.splice(idx + 1, 0, { id, tabs: tab ? [{ ...tab, preview: false }] : [], activeId: tab?.id ?? null });
952
+ return { groups, activeGroup: id };
953
+ });
954
+ this.persistTabs();
955
+ return id;
956
+ }
957
+ /** 옆 묶음에 연다 — 없으면 만든다. */
958
+ async openToSide(path) {
959
+ const groups = this.state.groups;
960
+ const idx = groups.findIndex((g) => g.id === this.state.activeGroup);
961
+ const target = groups[idx + 1]?.id ?? this.splitRight({ empty: true });
962
+ await this.openFile(path, { preview: false, groupId: target });
963
+ }
964
+ focusGroup(index) {
965
+ const g = this.state.groups[index];
966
+ if (g) {
967
+ this.set({ activeGroup: g.id });
968
+ this.editors.get(g.id)?.focus();
969
+ }
970
+ }
971
+ restoreTabs() {
972
+ const saved = this.readJson('tabs', {});
973
+ if (!saved.groups?.length)
974
+ return;
975
+ const known = new Set(this.state.files.map((f) => f.path));
976
+ const groups = [];
977
+ for (const sg of saved.groups.slice(0, 3)) {
978
+ const tabs = [];
979
+ for (const t of sg.tabs ?? []) {
980
+ if (t.kind !== 'diff' && !known.has(t.path))
981
+ continue;
982
+ tabs.push({ id: tabIdFor(t.kind, t.path, t.diff), kind: t.kind, path: t.path, preview: false, diff: t.diff });
983
+ }
984
+ if (!tabs.length)
985
+ continue;
986
+ const active = tabs.find((t) => t.path === sg.active && t.kind !== 'diff') ?? tabs[0];
987
+ groups.push({ id: newGroupId(), tabs, activeId: active.id });
988
+ }
989
+ if (!groups.length)
990
+ return;
991
+ const activeGroup = groups[Math.max(0, Math.min(saved.activeIndex ?? 0, groups.length - 1))].id;
992
+ this.set({ groups, activeGroup });
993
+ for (const g of groups) {
994
+ for (const t of g.tabs) {
995
+ if (t.kind === 'file')
996
+ void this.ensureDoc(t.path);
997
+ else if (t.kind === 'diff' && t.diff && !t.diff.staged)
998
+ void this.ensureDoc(t.path);
999
+ }
1000
+ }
1001
+ }
1002
+ // ── 파일 작업 ─────────────────────────────────────────────────────
1003
+ async runFs(op, done) {
1004
+ try {
1005
+ await this.host.fs(op);
1006
+ await this.refreshFiles();
1007
+ this.scheduleGit();
1008
+ this.set({ notice: { kind: 'success', message: done, at: Date.now() } });
1009
+ return true;
1010
+ }
1011
+ catch (err) {
1012
+ this.notify('error', errorMessage(err, '작업을 마치지 못했습니다'));
1013
+ return false;
1014
+ }
1015
+ }
1016
+ async createEntry(parent, name, kind) {
1017
+ const path = join(parent, name);
1018
+ this.set({ creating: null });
1019
+ if (this.exists(path)) {
1020
+ this.notify('warning', `${name} 은(는) 이미 있습니다`);
1021
+ return false;
1022
+ }
1023
+ if (kind === 'dir')
1024
+ return this.runFs({ op: 'mkdir', path }, `${name} 폴더를 만들었습니다`);
1025
+ try {
1026
+ await this.host.saveFile(path, new Uint8Array(), '');
1027
+ }
1028
+ catch (err) {
1029
+ this.notify('error', errorMessage(err, `${name} 을(를) 만들지 못했습니다`));
1030
+ return false;
1031
+ }
1032
+ await this.refreshFiles();
1033
+ this.scheduleGit();
1034
+ await this.openFile(path, { preview: false });
1035
+ return true;
1036
+ }
1037
+ async renameEntry(path, newName) {
1038
+ this.set({ renaming: null });
1039
+ const dst = join(dirname(path), newName);
1040
+ if (dst === path)
1041
+ return true;
1042
+ return this.moveEntry(path, dst);
1043
+ }
1044
+ /** 옮기기·이름 바꾸기 — 열린 탭과 고친 버퍼도 따라간다. */
1045
+ async moveEntry(src, dst) {
1046
+ if (isWithin(dst, src)) {
1047
+ this.notify('warning', '폴더를 자기 안으로 옮길 수 없습니다');
1048
+ return false;
1049
+ }
1050
+ const dirtyInside = Object.values(this.state.docs).filter((d) => d.dirty && isWithin(d.path, src));
1051
+ if (dirtyInside.length) {
1052
+ const ok = await this.confirm('저장하지 않은 파일이 있습니다', '옮기기 전에 저장합니다.', '저장하고 옮기기');
1053
+ if (!ok)
1054
+ return false;
1055
+ for (const d of dirtyInside)
1056
+ if (!(await this.save(d.path, { quiet: true })))
1057
+ return false;
1058
+ }
1059
+ const ok = await this.runFs({ op: 'rename', src, dst }, `${basename(dst)}(으)로 옮겼습니다`);
1060
+ if (!ok)
1061
+ return false;
1062
+ // 열린 탭을 새 경로로 옮긴다(문서는 새로 읽는다 — sha 는 같다).
1063
+ const moved = new Set();
1064
+ this.set((s) => ({
1065
+ groups: s.groups.map((g) => {
1066
+ let activeId = g.activeId;
1067
+ const tabs = g.tabs.map((t) => {
1068
+ if (t.kind === 'diff' || !isWithin(t.path, src))
1069
+ return t;
1070
+ const path = dst + t.path.slice(src.length);
1071
+ moved.add(t.path);
1072
+ const id = tabIdFor(t.kind, path);
1073
+ if (activeId === t.id)
1074
+ activeId = id;
1075
+ return { ...t, id, path };
1076
+ });
1077
+ return { ...g, tabs, activeId };
1078
+ }),
1079
+ }));
1080
+ for (const old of moved) {
1081
+ this.releaseDocIfUnused(old);
1082
+ const next = dst + old.slice(src.length);
1083
+ if (!isImagePath(next))
1084
+ void this.ensureDoc(next);
1085
+ }
1086
+ if (this.state.selected && isWithin(this.state.selected, src))
1087
+ this.select(dst + this.state.selected.slice(src.length));
1088
+ this.persistTabs();
1089
+ return true;
1090
+ }
1091
+ async deleteEntries(paths) {
1092
+ if (!paths.length || this.state.readonly)
1093
+ return false;
1094
+ const label = paths.length === 1 ? basename(paths[0]) : `${paths.length}개 항목`;
1095
+ const hasDir = paths.some((p) => this.isDir(p));
1096
+ const ok = await this.confirm(`${label} 을(를) 지울까요?`, hasDir ? '폴더와 그 안의 모든 파일을 지웁니다. 되돌릴 수 없습니다.' : '되돌릴 수 없습니다.', '지우기', true);
1097
+ if (!ok)
1098
+ return false;
1099
+ const done = await this.runFs({ op: 'delete', paths }, `${label} 을(를) 지웠습니다`);
1100
+ if (!done)
1101
+ return false;
1102
+ for (const g of [...this.state.groups]) {
1103
+ for (const t of g.tabs) {
1104
+ if (t.kind !== 'diff' && paths.some((p) => isWithin(t.path, p)))
1105
+ await this.closeTab(g.id, t.id, { force: true });
1106
+ }
1107
+ }
1108
+ return true;
1109
+ }
1110
+ async duplicateOrPaste(targetDir) {
1111
+ const clip = this.state.clipboard;
1112
+ if (!clip || this.state.readonly)
1113
+ return;
1114
+ const taken = new Set(this.childNames(targetDir));
1115
+ for (const src of clip.paths) {
1116
+ const name = basename(src);
1117
+ if (clip.mode === 'cut') {
1118
+ if (dirname(src) === targetDir)
1119
+ continue;
1120
+ let dst = join(targetDir, name);
1121
+ if (taken.has(name)) {
1122
+ const replace = await this.confirm(`${name} 이(가) 이미 있습니다`, '같은 이름이 있어 옮길 수 없습니다. 이름을 바꿔 옮길까요?', '이름 바꿔 옮기기');
1123
+ if (!replace)
1124
+ continue;
1125
+ dst = join(targetDir, uniqueCopyName(name, taken));
1126
+ }
1127
+ if (await this.moveEntry(src, dst))
1128
+ taken.add(basename(dst));
1129
+ }
1130
+ else {
1131
+ const dstName = uniqueCopyName(name, taken);
1132
+ taken.add(dstName);
1133
+ await this.runFs({ op: 'copy', src, dst: join(targetDir, dstName) }, `${dstName}(으)로 복사했습니다`);
1134
+ }
1135
+ }
1136
+ if (clip.mode === 'cut')
1137
+ this.set({ clipboard: null });
1138
+ }
1139
+ childNames(dir) {
1140
+ const prefix = dir ? dir + '/' : '';
1141
+ const out = new Set();
1142
+ for (const f of this.state.files) {
1143
+ if (!f.path.startsWith(prefix))
1144
+ continue;
1145
+ const rest = f.path.slice(prefix.length);
1146
+ if (rest)
1147
+ out.add(rest.split('/')[0]);
1148
+ }
1149
+ return [...out];
1150
+ }
1151
+ setClipboard(mode, paths) {
1152
+ this.set({ clipboard: paths.length ? { mode, paths } : null });
1153
+ }
1154
+ /** 운영체제에서 끌어다 놓은 파일을 올린다(같은 이름이 있으면 묻는다). */
1155
+ async upload(targetDir, files) {
1156
+ if (this.state.readonly)
1157
+ return;
1158
+ let done = 0;
1159
+ for (const f of files) {
1160
+ const path = join(targetDir, f.name);
1161
+ let base = '';
1162
+ if (this.exists(path)) {
1163
+ const ok = await this.confirm(`${f.name} 이(가) 이미 있습니다`, '올린 파일로 바꿀까요?', '바꾸기', true);
1164
+ if (!ok)
1165
+ continue;
1166
+ base = null;
1167
+ }
1168
+ try {
1169
+ await this.host.saveFile(path, f.bytes, base);
1170
+ done += 1;
1171
+ if (this.state.docs[path] && !this.state.docs[path].dirty)
1172
+ void this.reloadFromDisk(path);
1173
+ }
1174
+ catch (err) {
1175
+ this.notify('error', errorMessage(err, `${f.name} 을(를) 올리지 못했습니다`));
1176
+ }
1177
+ }
1178
+ if (done) {
1179
+ await this.refreshFiles();
1180
+ this.scheduleGit();
1181
+ this.set({ notice: { kind: 'success', message: `${done}개 파일을 올렸습니다`, at: Date.now() } });
1182
+ }
1183
+ }
1184
+ // ── 찾기 ──────────────────────────────────────────────────────────
1185
+ setSearch(patch) {
1186
+ this.set((s) => ({ search: { ...s.search, ...patch } }));
1187
+ }
1188
+ async runSearch() {
1189
+ const s = this.state.search;
1190
+ const query = s.query;
1191
+ if (!query) {
1192
+ this.setSearch({ result: null, error: null, running: false });
1193
+ return;
1194
+ }
1195
+ const seq = ++this.searchSeq;
1196
+ this.setSearch({ running: true, error: null });
1197
+ const q = {
1198
+ query,
1199
+ regex: s.regex,
1200
+ case: s.caseSensitive,
1201
+ word: s.word,
1202
+ include: s.include,
1203
+ exclude: s.exclude,
1204
+ };
1205
+ try {
1206
+ const result = await this.host.search(q);
1207
+ if (seq !== this.searchSeq)
1208
+ return;
1209
+ this.setSearch({ result, running: false, collapsed: new Set() });
1210
+ }
1211
+ catch (err) {
1212
+ if (seq !== this.searchSeq)
1213
+ return;
1214
+ this.setSearch({ running: false, error: errorMessage(err, '찾지 못했습니다'), result: null });
1215
+ }
1216
+ }
1217
+ async replaceInFiles(files) {
1218
+ const s = this.state.search;
1219
+ const targets = files ?? s.result?.files.map((f) => f.path) ?? [];
1220
+ if (!targets.length || this.state.readonly)
1221
+ return;
1222
+ const total = (s.result?.files ?? []).filter((f) => targets.includes(f.path)).reduce((n, f) => n + f.matches.length, 0);
1223
+ const ok = await this.confirm('모두 바꿀까요?', `${targets.length}개 파일의 ${total}개 결과를 "${s.replace}" (으)로 바꿉니다.`, '바꾸기', true);
1224
+ if (!ok)
1225
+ return;
1226
+ // 열려 있고 고친 파일은 먼저 저장한다 — 그대로 두면 바꾸기가 디스크를 고친 뒤 편집기 저장이 덮는다.
1227
+ for (const p of targets)
1228
+ if (this.state.docs[p]?.dirty && !(await this.save(p, { quiet: true })))
1229
+ return;
1230
+ try {
1231
+ const out = await this.host.replace({
1232
+ query: s.query,
1233
+ regex: s.regex,
1234
+ case: s.caseSensitive,
1235
+ word: s.word,
1236
+ include: s.include,
1237
+ exclude: s.exclude,
1238
+ replacement: s.replace,
1239
+ files: targets,
1240
+ });
1241
+ const n = out.changed.reduce((a, c) => a + c.count, 0);
1242
+ this.set({ notice: { kind: 'success', message: `${out.changed.length}개 파일에서 ${n}개를 바꿨습니다`, at: Date.now() } });
1243
+ for (const c of out.changed)
1244
+ if (this.state.docs[c.path])
1245
+ await this.reloadFromDisk(c.path);
1246
+ await this.runSearch();
1247
+ this.scheduleGit();
1248
+ }
1249
+ catch (err) {
1250
+ this.notify('error', errorMessage(err, '바꾸지 못했습니다'));
1251
+ }
1252
+ }
1253
+ // ── 소스 제어 ─────────────────────────────────────────────────────
1254
+ setGit(patch) {
1255
+ this.set((s) => ({ git: { ...s.git, ...patch } }));
1256
+ }
1257
+ scheduleGit(delay = 800) {
1258
+ if (this.gitTimer)
1259
+ clearTimeout(this.gitTimer);
1260
+ this.gitTimer = setTimeout(() => void this.refreshGit(), delay);
1261
+ }
1262
+ async refreshGit() {
1263
+ if (this.state.git.loading)
1264
+ return;
1265
+ this.setGit({ loading: true });
1266
+ try {
1267
+ const found = await this.host.git({ op: 'repos' });
1268
+ const repos = (found.repos ?? []).map((r) => r.path);
1269
+ const statuses = {};
1270
+ await Promise.all(repos.map(async (repo) => {
1271
+ try {
1272
+ statuses[repo] = await this.host.git({ op: 'status', repo });
1273
+ }
1274
+ catch {
1275
+ /* 한 저장소가 망가져도 나머지는 보인다 */
1276
+ }
1277
+ }));
1278
+ this.setGit({ repos, statuses, loading: false, loaded: true, error: null });
1279
+ }
1280
+ catch (err) {
1281
+ const code = err instanceof IdeError ? err.code : 'error';
1282
+ this.setGit({ loading: false, loaded: true, error: { code, message: gitErrorMessage(code, errorMessage(err, '')) } });
1283
+ }
1284
+ }
1285
+ async refreshAccount() {
1286
+ try {
1287
+ const account = await this.host.git({ op: 'account' });
1288
+ this.setGit({ account });
1289
+ }
1290
+ catch {
1291
+ /* 계정 정보는 보조다 */
1292
+ }
1293
+ }
1294
+ setCommitMessage(repo, message) {
1295
+ this.setGit({ message: { ...this.state.git.message, [repo]: message } });
1296
+ }
1297
+ /** git 동작 하나 — 실패는 알맞은 다음 행동과 함께 알린다. 성공하면 상태를 다시 본다. */
1298
+ async gitRun(args, label) {
1299
+ if (this.state.git.busy)
1300
+ return null;
1301
+ this.setGit({ busy: label });
1302
+ try {
1303
+ const out = await this.host.git(args);
1304
+ this.setGit({ busy: null });
1305
+ await this.refreshGit();
1306
+ if (['checkout', 'pull', 'sync', 'merge', 'stash', 'discard', 'clone'].includes(String(args.op))) {
1307
+ await this.refreshFiles();
1308
+ await this.checkDisk('all');
1309
+ }
1310
+ return out;
1311
+ }
1312
+ catch (err) {
1313
+ this.setGit({ busy: null });
1314
+ const code = err instanceof IdeError ? err.code : 'error';
1315
+ const msg = gitErrorMessage(code, errorMessage(err, ''));
1316
+ if (code === 'auth') {
1317
+ const go = await this.confirm('로그인이 필요합니다', msg, '토큰 등록');
1318
+ if (go)
1319
+ await this.promptCredential(this.remoteHostOf(String(args.repo ?? '')));
1320
+ }
1321
+ else if (code === 'identity') {
1322
+ const go = await this.confirm('커밋할 이름이 없습니다', msg, '이름과 메일 설정');
1323
+ if (go)
1324
+ await this.promptIdentity();
1325
+ }
1326
+ else {
1327
+ this.notify('error', msg);
1328
+ }
1329
+ await this.refreshGit();
1330
+ return null;
1331
+ }
1332
+ }
1333
+ remoteHostOf(repo) {
1334
+ const st = this.state.git.statuses[repo];
1335
+ const r = st?.remotes.find((x) => x.name === 'origin') ?? st?.remotes[0];
1336
+ return r?.protocol === 'https' || r?.protocol === 'http' ? r.host : '';
1337
+ }
1338
+ async promptIdentity() {
1339
+ const cur = this.state.git.account?.identity ?? { name: '', email: '' };
1340
+ const r = await this.ask({
1341
+ title: '커밋할 이름과 메일',
1342
+ message: '이 에이전트의 샌드박스에서 만드는 커밋에 쓰입니다.',
1343
+ fields: [
1344
+ { id: 'name', label: '이름', value: cur.name, placeholder: '홍길동' },
1345
+ { id: 'email', label: '메일', value: cur.email, placeholder: 'name@example.com' },
1346
+ ],
1347
+ buttons: [
1348
+ { id: 'ok', label: '저장', primary: true },
1349
+ { id: 'cancel', label: '취소' },
1350
+ ],
1351
+ });
1352
+ if (r.button !== 'ok')
1353
+ return;
1354
+ const out = await this.gitRun({ op: 'identity', name: r.fields.name, email: r.fields.email }, '이름 저장');
1355
+ if (out)
1356
+ await this.refreshAccount();
1357
+ }
1358
+ async promptCredential(host = '') {
1359
+ const r = await this.ask({
1360
+ title: '원격 저장소 토큰 등록',
1361
+ message: 'HTTPS 로 푸시·풀할 때 쓰는 개인 액세스 토큰입니다. 이 에이전트도 같은 토큰으로 푸시할 수 있습니다.',
1362
+ fields: [
1363
+ { id: 'host', label: '호스트', value: host, placeholder: 'github.com' },
1364
+ { id: 'username', label: '사용자 이름', value: '', placeholder: 'GitHub·GitLab 계정 이름' },
1365
+ { id: 'token', label: '토큰', value: '', placeholder: '개인 액세스 토큰', password: true },
1366
+ ],
1367
+ buttons: [
1368
+ { id: 'ok', label: '등록', primary: true },
1369
+ { id: 'cancel', label: '취소' },
1370
+ ],
1371
+ });
1372
+ if (r.button !== 'ok')
1373
+ return;
1374
+ const out = await this.gitRun({ op: 'credential_set', host: r.fields.host, username: r.fields.username, token: r.fields.token }, '토큰 등록');
1375
+ if (out) {
1376
+ await this.refreshAccount();
1377
+ this.set({ notice: { kind: 'success', message: '토큰을 등록했습니다', at: Date.now() } });
1378
+ }
1379
+ }
1380
+ async removeCredential(host, username) {
1381
+ const ok = await this.confirm(`${host} 토큰을 지울까요?`, '이 에이전트의 샌드박스에서 이 호스트로 푸시할 수 없게 됩니다.', '지우기', true);
1382
+ if (!ok)
1383
+ return;
1384
+ const out = await this.gitRun({ op: 'credential_remove', host, username }, '토큰 삭제');
1385
+ if (out)
1386
+ await this.refreshAccount();
1387
+ }
1388
+ // ── 터미널 ────────────────────────────────────────────────────────
1389
+ addTerminal(info) {
1390
+ const id = info?.id ?? `t${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
1391
+ const n = this.state.terminals.length + 1;
1392
+ this.set((s) => ({
1393
+ terminals: [...s.terminals, { id, title: `bash ${n}`, status: 'idle', cwd: info?.cwd }],
1394
+ activeTerminal: id,
1395
+ layout: { ...s.layout, panelOpen: true },
1396
+ }));
1397
+ this.writeJson('layout', this.state.layout);
1398
+ return id;
1399
+ }
1400
+ setTerminal(id, patch) {
1401
+ this.set((s) => ({ terminals: s.terminals.map((t) => (t.id === id ? { ...t, ...patch } : t)) }));
1402
+ }
1403
+ setActiveTerminal(id) {
1404
+ this.set({ activeTerminal: id });
1405
+ }
1406
+ async killTerminal(id) {
1407
+ const rt = this.terminalRuntimes.get(id);
1408
+ this.terminalRuntimes.delete(id);
1409
+ rt?.dispose();
1410
+ this.set((s) => {
1411
+ const terminals = s.terminals.filter((t) => t.id !== id);
1412
+ const activeTerminal = s.activeTerminal === id ? terminals[terminals.length - 1]?.id ?? null : s.activeTerminal;
1413
+ // 마지막 터미널을 닫으면 패널도 닫는다(새 셸을 저절로 띄우지 않는다).
1414
+ const layout = terminals.length ? s.layout : { ...s.layout, panelOpen: false, panelMaximized: false };
1415
+ return { terminals, activeTerminal, layout };
1416
+ });
1417
+ this.writeJson('layout', this.state.layout);
1418
+ try {
1419
+ await this.host.closeTerminal(id);
1420
+ }
1421
+ catch {
1422
+ /* 이미 닫혔다 */
1423
+ }
1424
+ }
1425
+ togglePanel() {
1426
+ this.setLayout({ panelOpen: !this.state.layout.panelOpen });
1427
+ }
1428
+ /** 터미널을 보이게 — 없으면 하나 연다. */
1429
+ showTerminal(newOne = false, cwd) {
1430
+ if (this.state.readonly)
1431
+ return;
1432
+ if (newOne || !this.state.terminals.length)
1433
+ this.addTerminal({ cwd });
1434
+ else
1435
+ this.setLayout({ panelOpen: true });
1436
+ }
1437
+ }
1438
+ export function terminalTitle(t, n) {
1439
+ return `${t.shell || 'bash'} ${n}`;
1440
+ }
1441
+ export function errorMessage(err, fallback) {
1442
+ if (err instanceof IdeError)
1443
+ return err.message || fallback;
1444
+ if (err instanceof Error && err.message)
1445
+ return err.message;
1446
+ return fallback;
1447
+ }
1448
+ let cancellationGuard = false;
1449
+ /**
1450
+ * Monaco 는 편집기·모델을 치우며 진행 중이던 요청(단어 강조·언어 worker)을 취소하고, 그 취소를
1451
+ * 잡히지 않은 거부(`Canceled`)로 흘린다. VS Code 는 이것을 오류로 치지 않는다 — 여기서도 그것 하나만
1452
+ * 조용히 삼킨다(다른 오류는 그대로 보인다).
1453
+ */
1454
+ function ignoreMonacoCancellation() {
1455
+ if (cancellationGuard || typeof window === 'undefined')
1456
+ return;
1457
+ cancellationGuard = true;
1458
+ window.addEventListener('unhandledrejection', (e) => {
1459
+ const r = e.reason;
1460
+ if (r && r.name === 'Canceled' && r.message === 'Canceled')
1461
+ e.preventDefault();
1462
+ });
1463
+ }
1464
+ //# sourceMappingURL=store.js.map