feat: v0.8.0 edit and save files with conflict detection
- devdesk attach --fs-write SESSION PATH BYTES BASESHA|new: streamed like uploads, temp file in the same folder, sha re-check (conflict reply instead of overwriting), mode/owner kept, atomic mv; new files with folders, never outside the session folder or through a symlink out of it. - Editor: editable CodeMirror for UTF-8 text, dirty tracking, Ctrl+S, Save/Revert, conflict dialog with a disk-vs-yours diff, changed-on-disk banner, drafts in localStorage (flushed on pagehide), close/leave guards, New file. - Upload streaming factored into streamIn(), shared by uploads and saves. Closes #20 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+325
-13
@@ -5,11 +5,11 @@
|
||||
// see what Claude edited, and jump to any file by name. Read-only for now.
|
||||
// Everything goes through `devdesk attach --fs-*` over ttyd.
|
||||
|
||||
import { runBlob, runCommand } from './connection.js';
|
||||
import { runBlob, runCommand, writeFile } from './connection.js';
|
||||
import { parseListOutput } from './protocol.js';
|
||||
import { el } from './dom.js';
|
||||
import { ago } from './sessions.js';
|
||||
import { CommandPalette, toast } from './ui.js';
|
||||
import { CommandPalette, openDialog, toast } from './ui.js';
|
||||
|
||||
// ---------------------------------------------------------------- editor bundle
|
||||
|
||||
@@ -164,6 +164,27 @@ function writeJson(key, value) {
|
||||
}
|
||||
}
|
||||
|
||||
// Unsaved edits survive a reload, a session switch or a crash (devdesk#20):
|
||||
// each dirty buffer is mirrored to localStorage with the sha it was based on.
|
||||
const draftKey = (session, path) => `devdesk.draft.${session}:${path}`;
|
||||
|
||||
function readDraft(session, path) {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(draftKey(session, path)));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeDraft(session, path, draft) {
|
||||
try {
|
||||
if (draft) localStorage.setItem(draftKey(session, path), JSON.stringify(draft));
|
||||
else localStorage.removeItem(draftKey(session, path));
|
||||
} catch {
|
||||
// Quota or private mode: the edit just isn't backed up.
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- icons
|
||||
|
||||
// A two-character badge per file type (VS Code's icon idea, in the terminal style).
|
||||
@@ -249,6 +270,7 @@ export class FilesPane {
|
||||
root.querySelector('.dd-files-refresh').addEventListener('click', () => this.refresh());
|
||||
root.querySelector('.dd-files-close').addEventListener('click', () => this.close());
|
||||
root.querySelector('.dd-files-find').addEventListener('click', () => this.quickOpen.open());
|
||||
root.querySelector('.dd-files-new').addEventListener('click', () => this.newFile());
|
||||
root.querySelector('.dd-explorer-toggle').addEventListener('click', () => this.setExplorer(!this.explorerShown()));
|
||||
this.explorer.addEventListener('keydown', (event) => this.treeKey(event));
|
||||
root.addEventListener('keydown', (event) => {
|
||||
@@ -256,8 +278,15 @@ export class FilesPane {
|
||||
event.preventDefault();
|
||||
this.quickOpen.open();
|
||||
}
|
||||
if ((event.ctrlKey || event.metaKey) && !event.shiftKey && !event.altKey && event.code === 'KeyS') {
|
||||
event.preventDefault();
|
||||
if (this.active) this.save(this.active);
|
||||
}
|
||||
});
|
||||
this.wireExplorerResize();
|
||||
// Leaving or reloading the page: write pending drafts now, not in 400 ms.
|
||||
window.addEventListener('pagehide', () => this.flushDrafts());
|
||||
window.addEventListener('beforeunload', () => this.flushDrafts());
|
||||
// Narrow pane (phone, or dragged small): the explorer slides over the editor.
|
||||
new ResizeObserver(() => root.classList.toggle('is-narrow', root.clientWidth < 680)).observe(root);
|
||||
this.quickOpen = new CommandPalette(() => this.loadFileList(), {
|
||||
@@ -343,6 +372,7 @@ export class FilesPane {
|
||||
syncSession() {
|
||||
const session = this.hooks.session();
|
||||
if (session === this.session) return false;
|
||||
this.flushDrafts();
|
||||
for (const v of this.views.values()) v.editor?.destroy();
|
||||
this.views.clear();
|
||||
this.listings.clear();
|
||||
@@ -689,7 +719,11 @@ export class FilesPane {
|
||||
this.tabs[preview] = tab;
|
||||
} else {
|
||||
this.tabs.push(tab);
|
||||
while (this.tabs.length > 12) this.closeTab(this.tabs.find((t) => t.key !== key).key, { render: false });
|
||||
while (this.tabs.length > 12) {
|
||||
const victim = this.tabs.find((t) => t.key !== key && !this.views.get(t.key)?.dirty);
|
||||
if (!victim) break;
|
||||
this.closeTab(victim.key, { render: false, force: true });
|
||||
}
|
||||
}
|
||||
} else if (pin) {
|
||||
tab.pinned = true;
|
||||
@@ -716,7 +750,34 @@ export class FilesPane {
|
||||
this.renderExplorer();
|
||||
}
|
||||
|
||||
closeTab(key, { render = true } = {}) {
|
||||
flushDrafts() {
|
||||
for (const [key, view] of this.views) {
|
||||
if (!view.editable || !view.draftTimer) continue;
|
||||
clearTimeout(view.draftTimer);
|
||||
view.draftTimer = 0;
|
||||
const tab = this.tabs.find((t) => t.key === key);
|
||||
if (tab) writeDraft(this.session, tab.path, view.dirty ? { text: view.editor.text(), base: view.base, at: Date.now() } : null);
|
||||
}
|
||||
}
|
||||
|
||||
hasDirty() {
|
||||
return [...this.views.values()].some((v) => v.dirty);
|
||||
}
|
||||
|
||||
async closeTab(key, { render = true, force = false } = {}) {
|
||||
const view = this.views.get(key);
|
||||
const tab = this.tabs.find((t) => t.key === key);
|
||||
if (!force && view?.dirty && tab) {
|
||||
const choice = await openDialog({
|
||||
title: `Save changes to ${base(tab.path)}?`,
|
||||
size: 'sm',
|
||||
body: el('p', { class: 'dd-dialog-text' }, `${tab.path} has unsaved changes.`),
|
||||
actions: [{ label: 'Cancel', value: null }, { label: 'Don\'t save', value: 'discard' }, { label: 'Save', primary: true }],
|
||||
});
|
||||
if (!choice) return;
|
||||
if (choice === 'discard') writeDraft(this.session, tab.path, null);
|
||||
else if (!(await this.save(key))) return;
|
||||
}
|
||||
const i = this.tabs.findIndex((t) => t.key === key);
|
||||
if (i < 0) return;
|
||||
this.tabs.splice(i, 1);
|
||||
@@ -731,6 +792,7 @@ export class FilesPane {
|
||||
}
|
||||
|
||||
dropView(key) {
|
||||
clearTimeout(this.views.get(key)?.draftTimer);
|
||||
this.views.get(key)?.editor?.destroy();
|
||||
this.views.get(key)?.node.remove();
|
||||
this.views.delete(key);
|
||||
@@ -742,7 +804,7 @@ export class FilesPane {
|
||||
const change = this.changeFor(tab.path);
|
||||
const status = change ? statusOf(change) : null;
|
||||
return el('div', {
|
||||
class: `dd-tab${tab.key === this.active ? ' is-active' : ''}${tab.pinned ? '' : ' is-preview'}${status ? ` git-${status[2]}` : ''}`,
|
||||
class: `dd-tab${tab.key === this.active ? ' is-active' : ''}${tab.pinned ? '' : ' is-preview'}${status ? ` git-${status[2]}` : ''}${this.views.get(tab.key)?.dirty ? ' is-dirty' : ''}`,
|
||||
role: 'tab', 'aria-selected': String(tab.key === this.active), tabindex: tab.key === this.active ? '0' : '-1',
|
||||
title: `${tab.path}${tab.mode === 'diff' ? ' (diff against HEAD)' : ''}${tab.pinned ? '' : ' · preview: the next file replaces it; double-click to keep'}`,
|
||||
onclick: () => this.activate(tab.key),
|
||||
@@ -758,12 +820,12 @@ export class FilesPane {
|
||||
el('span', { class: 'dd-tab-name' }, name),
|
||||
tab.mode === 'diff' ? el('span', { class: 'dd-tab-tag' }, 'diff') : null,
|
||||
el('button', {
|
||||
type: 'button', class: 'dd-tab-close', 'aria-label': `Close ${name}`, title: 'Close',
|
||||
type: 'button', class: 'dd-tab-close', 'aria-label': `Close ${name}`, title: this.views.get(tab.key)?.dirty ? 'Unsaved changes: close…' : 'Close',
|
||||
onclick: (event) => {
|
||||
event.stopPropagation();
|
||||
this.closeTab(tab.key);
|
||||
},
|
||||
}, '×'));
|
||||
}, this.views.get(tab.key)?.dirty ? '●' : '×'));
|
||||
});
|
||||
this.tabStrip.replaceChildren(...nodes);
|
||||
this.tabStrip.querySelector('.is-active')?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
||||
@@ -851,6 +913,10 @@ export class FilesPane {
|
||||
const [{ blob, original }, editor] = await Promise.all([this.fetchTab(tab), loadEditor().catch((error) => ({ error }))]);
|
||||
if (session !== this.session || this.views.get(key) !== view) return;
|
||||
if (blob.header.sha && blob.header.sha === view.header?.sha && tab.mode !== 'diff') return;
|
||||
if (view.dirty) {
|
||||
if (blob.header.sha !== view.base) this.changedOnDisk(key);
|
||||
return;
|
||||
}
|
||||
const scroller = view.node.querySelector('.cm-scroller');
|
||||
const top = scroller?.scrollTop ?? 0;
|
||||
view.editor?.destroy();
|
||||
@@ -863,7 +929,7 @@ export class FilesPane {
|
||||
}
|
||||
}
|
||||
|
||||
bar(tab, header) {
|
||||
bar(tab, header, view) {
|
||||
const change = this.changeFor(tab.path);
|
||||
const meta = header ? [
|
||||
size(header.size ?? 0),
|
||||
@@ -876,6 +942,10 @@ export class FilesPane {
|
||||
const button = (label, run, extra = {}) => el('button', { type: 'button', class: 'dd-viewer-btn', onclick: run, ...extra }, label);
|
||||
const switchMode = (mode) => this.openFile(tab.path, { mode, pin: true });
|
||||
const actions = [];
|
||||
if (view?.editable) {
|
||||
actions.push(button('Save', () => this.save(tab.key), { class: 'dd-viewer-btn is-primary dd-dirty-only', title: 'Save (Ctrl+S)' }));
|
||||
actions.push(button('Revert', () => this.revert(tab.key), { class: 'dd-viewer-btn dd-dirty-only', title: 'Discard your changes' }));
|
||||
}
|
||||
if (tab.mode !== 'diff' && isMarkdown(tab.path)) actions.push(button(tab.mode === 'preview' ? 'Source' : 'Preview', () => switchMode(tab.mode === 'preview' ? 'view' : 'preview')));
|
||||
if (change && change.x !== '?' && change.y !== '?' && this.changes?.git) {
|
||||
actions.push(button(tab.mode === 'diff' ? 'File' : 'Diff', () => switchMode(tab.mode === 'diff' ? (isMarkdown(tab.path) ? 'preview' : 'view') : 'diff')));
|
||||
@@ -897,7 +967,8 @@ export class FilesPane {
|
||||
return el('div', { class: 'dd-viewer-bar' },
|
||||
el('span', { class: 'dd-viewer-where', title: [tab.path, meta].filter(Boolean).join('\n') },
|
||||
el('span', { class: 'dd-viewer-path' }, tab.path),
|
||||
meta ? el('span', { class: 'dd-viewer-meta' }, ` · ${meta}`) : null),
|
||||
meta ? el('span', { class: 'dd-viewer-meta' }, ` · ${meta}`) : null,
|
||||
view?.editable ? el('span', { class: 'dd-unsaved dd-dirty-only' }, ' · unsaved') : null),
|
||||
el('span', { class: 'dd-viewer-actions' }, ...actions));
|
||||
}
|
||||
|
||||
@@ -917,20 +988,261 @@ export class FilesPane {
|
||||
content.append(el('pre', { class: 'dd-plain' }, text(base64, header.utf8)));
|
||||
} else if (tab.mode === 'preview') {
|
||||
content.classList.add('is-preview');
|
||||
// Unsaved edits in the Source tab show in the preview too.
|
||||
const source = this.views.get(`view:${tab.path}`);
|
||||
const md = source?.dirty ? source.editor.text() : text(base64);
|
||||
const frame = el('iframe', {
|
||||
class: 'dd-md-frame', title: `Preview of ${tab.path}`,
|
||||
sandbox: 'allow-popups allow-popups-to-escape-sandbox', srcdoc: previewDocument(editor.markdown(text(base64))),
|
||||
sandbox: 'allow-popups allow-popups-to-escape-sandbox', srcdoc: previewDocument(editor.markdown(md)),
|
||||
});
|
||||
content.append(frame);
|
||||
inlineImages(editor.markdown(text(base64)), this.session, tab.path).then((html) => {
|
||||
inlineImages(editor.markdown(md), this.session, tab.path).then((html) => {
|
||||
if (frame.isConnected) frame.srcdoc = previewDocument(html);
|
||||
});
|
||||
} else if (tab.mode === 'diff') {
|
||||
view.editor = editor.diff(content, { original: text(original?.base64), modified: text(base64), path: tab.path, wrap: this.wrap });
|
||||
} else if (header.utf8 === false) {
|
||||
// Not UTF-8: shown as Latin-1, read-only (saving would change the bytes).
|
||||
view.editor = editor.viewer(content, { doc: text(base64, false), path: tab.path, wrap: this.wrap });
|
||||
} else {
|
||||
view.editor = editor.viewer(content, { doc: text(base64, header.utf8), path: tab.path, wrap: this.wrap });
|
||||
const disk = text(base64);
|
||||
const draft = readDraft(this.session, tab.path);
|
||||
let doc = disk;
|
||||
let banner = null;
|
||||
if (draft && draft.text !== disk) {
|
||||
if (draft.base === header.sha) {
|
||||
doc = draft.text;
|
||||
toast.info(`Restored your unsaved changes to ${tab.path}`);
|
||||
} else {
|
||||
banner = draft;
|
||||
}
|
||||
}
|
||||
view.editable = true;
|
||||
view.saved = disk;
|
||||
view.base = header.sha;
|
||||
view.editor = editor.editor(content, {
|
||||
doc, path: tab.path, wrap: this.wrap,
|
||||
onChange: (now) => this.edited(tab.key, now),
|
||||
onSave: () => this.save(tab.key),
|
||||
});
|
||||
view.dirty = doc !== disk;
|
||||
view.node.classList.toggle('is-dirty', view.dirty);
|
||||
view.node.replaceChildren(this.bar(tab, header, view), content);
|
||||
if (banner) this.oldDraft(tab.key, banner);
|
||||
if (view.dirty) this.renderTabs();
|
||||
return;
|
||||
}
|
||||
view.node.replaceChildren(this.bar(tab, header, view), content);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ editing (devdesk#20)
|
||||
|
||||
edited(key, now) {
|
||||
const view = this.views.get(key);
|
||||
const tab = this.tabs.find((t) => t.key === key);
|
||||
if (!view || !tab) return;
|
||||
const dirty = now !== view.saved;
|
||||
if (dirty && !tab.pinned) {
|
||||
tab.pinned = true; // editing keeps a preview tab
|
||||
this.saveState();
|
||||
}
|
||||
clearTimeout(view.draftTimer);
|
||||
view.draftTimer = setTimeout(() => writeDraft(this.session, tab.path, dirty ? { text: now, base: view.base, at: Date.now() } : null), 400);
|
||||
if (dirty !== view.dirty) {
|
||||
view.dirty = dirty;
|
||||
view.node.classList.toggle('is-dirty', dirty);
|
||||
this.renderTabs();
|
||||
}
|
||||
}
|
||||
|
||||
// Resolves true when saved. A conflict (the file changed on disk since it was
|
||||
// opened) asks before overwriting.
|
||||
async save(key, { base: forced } = {}) {
|
||||
const view = this.views.get(key);
|
||||
const tab = this.tabs.find((t) => t.key === key);
|
||||
if (!view?.editable || !tab || view.saving) return false;
|
||||
if (!view.dirty && !forced) return true;
|
||||
const now = view.editor.text();
|
||||
view.saving = true;
|
||||
try {
|
||||
const result = await writeFile(this.session, tab.path, new TextEncoder().encode(now), forced ?? view.base ?? 'new');
|
||||
if (result.conflict) return this.conflict(key, result.sha);
|
||||
if (!result.ok) throw new Error('not saved');
|
||||
view.saved = now;
|
||||
view.base = result.sha;
|
||||
view.header = { ...view.header, sha: result.sha, size: result.size, mtime: result.mtime };
|
||||
clearTimeout(view.draftTimer);
|
||||
writeDraft(this.session, tab.path, null);
|
||||
this.edited(key, view.editor.text());
|
||||
view.node.querySelector('.dd-viewer-banner')?.remove();
|
||||
toast.success(`Saved ${tab.path}`);
|
||||
this.loadChanges();
|
||||
this.fileList = null;
|
||||
return true;
|
||||
} catch (error) {
|
||||
toast.error(`Could not save ${tab.path}: ${error.message}`);
|
||||
return false;
|
||||
} finally {
|
||||
view.saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
revert(key) {
|
||||
const view = this.views.get(key);
|
||||
const tab = this.tabs.find((t) => t.key === key);
|
||||
if (!view?.editable || !tab) return;
|
||||
clearTimeout(view.draftTimer);
|
||||
writeDraft(this.session, tab.path, null);
|
||||
view.editor.setText(view.saved);
|
||||
view.node.querySelector('.dd-viewer-banner')?.remove();
|
||||
}
|
||||
|
||||
async diskText(path) {
|
||||
const { header, base64 } = await runBlob(['--fs-read', this.session, path]);
|
||||
return { sha: header.sha, text: new TextDecoder().decode(decodeBase64(base64 || '')) };
|
||||
}
|
||||
|
||||
async conflict(key, diskSha) {
|
||||
const tab = this.tabs.find((t) => t.key === key);
|
||||
const choice = await openDialog({
|
||||
title: `${base(tab.path)} changed on disk`,
|
||||
size: 'sm',
|
||||
body: el('p', { class: 'dd-dialog-text' }, `Someone (probably Claude) changed ${tab.path} after you opened it. Saving now would overwrite that.`),
|
||||
actions: [{ label: 'Cancel', value: null }, { label: 'Compare', value: 'compare' }, { label: 'Overwrite', primary: true, danger: true }],
|
||||
danger: true,
|
||||
});
|
||||
if (choice === 'compare') return this.compare(key);
|
||||
if (choice) return this.save(key, { base: diskSha });
|
||||
return false;
|
||||
}
|
||||
|
||||
// Disk version vs yours, side by side in a unified diff; pick one.
|
||||
async compare(key) {
|
||||
const view = this.views.get(key);
|
||||
const tab = this.tabs.find((t) => t.key === key);
|
||||
if (!view || !tab) return false;
|
||||
let disk;
|
||||
let editor;
|
||||
try {
|
||||
[disk, editor] = await Promise.all([this.diskText(tab.path), loadEditor()]);
|
||||
} catch (error) {
|
||||
toast.error(error.message);
|
||||
return false;
|
||||
}
|
||||
const holder = el('div', { class: 'dd-compare' });
|
||||
let diffView = null;
|
||||
const choice = await openDialog({
|
||||
title: `${tab.path}: disk (red) vs yours (green)`,
|
||||
className: 'is-wide',
|
||||
body: holder,
|
||||
onOpen: () => {
|
||||
diffView = editor.diff(holder, { original: disk.text, modified: view.editor.text(), path: tab.path, wrap: this.wrap });
|
||||
return holder.querySelector('.cm-content');
|
||||
},
|
||||
actions: [{ label: 'Close', value: null }, { label: 'Take the disk version', value: 'disk' }, { label: 'Save mine', primary: true }],
|
||||
});
|
||||
diffView?.destroy();
|
||||
if (choice === 'disk') {
|
||||
view.saved = disk.text;
|
||||
view.base = disk.sha;
|
||||
view.header = { ...view.header, sha: disk.sha };
|
||||
view.editor.setText(disk.text);
|
||||
writeDraft(this.session, tab.path, null);
|
||||
view.node.querySelector('.dd-viewer-banner')?.remove();
|
||||
return false;
|
||||
}
|
||||
if (choice) return this.save(key, { base: disk.sha });
|
||||
return false;
|
||||
}
|
||||
|
||||
banner(key, text, actions) {
|
||||
const view = this.views.get(key);
|
||||
if (!view) return;
|
||||
view.node.querySelector('.dd-viewer-banner')?.remove();
|
||||
const node = el('div', { class: 'dd-viewer-banner', role: 'alert' }, el('span', {}, text),
|
||||
...actions.map(([label, run]) => el('button', { type: 'button', class: 'dd-viewer-btn', onclick: run }, label)));
|
||||
view.node.querySelector('.dd-viewer-bar')?.after(node);
|
||||
}
|
||||
|
||||
// Claude changed a file you're editing: nothing is replaced until you choose.
|
||||
changedOnDisk(key) {
|
||||
this.banner(key, 'This file changed on disk while you were editing it.', [
|
||||
['Compare', () => this.compare(key)],
|
||||
['Reload (discard mine)', () => {
|
||||
this.revert(key);
|
||||
this.dropView(key);
|
||||
this.renderEditor();
|
||||
}],
|
||||
['Keep mine', () => this.views.get(key)?.node.querySelector('.dd-viewer-banner')?.remove()],
|
||||
]);
|
||||
}
|
||||
|
||||
// Unsaved changes from an earlier visit, but the file changed since.
|
||||
oldDraft(key, draft) {
|
||||
const view = this.views.get(key);
|
||||
const tab = this.tabs.find((t) => t.key === key);
|
||||
const age = ago(Math.floor(draft.at / 1000));
|
||||
this.banner(key, `You have unsaved changes from ${age === 'now' ? 'a moment ago' : `${age} ago`}, but the file changed on disk since.`, [
|
||||
['Compare', async () => {
|
||||
view.editor.setText(draft.text);
|
||||
await this.compare(key);
|
||||
}],
|
||||
['Restore mine', () => {
|
||||
view.editor.setText(draft.text);
|
||||
view.node.querySelector('.dd-viewer-banner')?.remove();
|
||||
}],
|
||||
['Discard', () => {
|
||||
writeDraft(this.session, tab.path, null);
|
||||
view.node.querySelector('.dd-viewer-banner')?.remove();
|
||||
}],
|
||||
]);
|
||||
}
|
||||
|
||||
// New file in the folder you're looking at (folders are created as needed).
|
||||
async newFile() {
|
||||
if (!this.session) return;
|
||||
const activePath = this.tabs.find((t) => t.key === this.active)?.path;
|
||||
const row = this.treeRows?.[this.focusIndex];
|
||||
const folder = row?.kind === 'dir' ? row.path : parent(row?.path ?? activePath ?? '');
|
||||
const input = el('input', { class: 'lt-input', value: folder ? `${folder}/` : '', spellcheck: 'false', autocomplete: 'off', 'aria-describedby': 'dd-newfile-hint' });
|
||||
const hint = el('p', { class: 'dd-dialog-hint', id: 'dd-newfile-hint' }, `Relative to ${this.listings.get('')?.root ?? 'the session folder'}. Missing folders are created.`);
|
||||
const path = await openDialog({
|
||||
title: 'New file',
|
||||
size: 'sm',
|
||||
body: el('div', { class: 'dd-dialog-fields' }, el('label', { class: 'dd-field' }, el('span', {}, 'Path'), input), hint),
|
||||
actions: [{ label: 'Cancel' }, { label: 'Create', primary: true }],
|
||||
onOpen: () => {
|
||||
input.setSelectionRange(input.value.length, input.value.length);
|
||||
return input;
|
||||
},
|
||||
submit: () => {
|
||||
const value = input.value.trim().replace(/^\/+/, '');
|
||||
if (!value || value.endsWith('/') || value.split('/').some((part) => part === '..' || part === '.')) {
|
||||
hint.textContent = 'Type a file name (no .. or trailing /).';
|
||||
hint.classList.add('is-error');
|
||||
return null;
|
||||
}
|
||||
return value;
|
||||
},
|
||||
});
|
||||
if (!path) return;
|
||||
try {
|
||||
const result = await writeFile(this.session, path, new Uint8Array(0), 'new');
|
||||
if (!result.ok) throw new Error('not created');
|
||||
// Show it: re-list every folder above the new file and open them.
|
||||
const parts = result.path.split('/').slice(0, -1);
|
||||
for (let i = 0; i <= parts.length; i += 1) {
|
||||
const dir = parts.slice(0, i).join('/');
|
||||
if (dir) this.expanded.add(dir);
|
||||
this.listDir(dir);
|
||||
}
|
||||
this.saveState();
|
||||
this.fileList = null;
|
||||
this.openFile(result.path, { mode: 'view', pin: true });
|
||||
toast.success(`Created ${result.path}`);
|
||||
} catch (error) {
|
||||
toast.error(`Could not create ${path}: ${error.message}`);
|
||||
}
|
||||
view.node.replaceChildren(this.bar(tab, header), content);
|
||||
}
|
||||
|
||||
// Theme switch: the editor follows CSS variables; previews need a re-render.
|
||||
|
||||
Reference in New Issue
Block a user