From 2ca4db39e0768043f422e7532c4a4f0bdd383fa2 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Wed, 11 Nov 2026 22:54:45 +0530 Subject: [PATCH] Keep the editor's unsaved and on-disk questions readable for long paths The editor dialog cut its whole message from the right, so a file whose shown path was longer than the panel lost 'has unsaved changes.' or 'changed on disk'. When the message starts with the file's path, the path now gives way from the left and keeps its file name. This also explains why the existing Esc dialog test failed locally when NMSH_TEST_TMPDIR gave a longer temp root than CI's. --- src/viewer/EditorPanel.ts | 17 ++++++++++++++++- tests/textEditor.test.ts | 25 +++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/src/viewer/EditorPanel.ts b/src/viewer/EditorPanel.ts index cb256624..59106e44 100644 --- a/src/viewer/EditorPanel.ts +++ b/src/viewer/EditorPanel.ts @@ -693,7 +693,7 @@ function footerRows(state: EditorState, columns: number): string[] { const actions = renderActionRow(dialogControls(dialog), {columns: width, keys: true, color: colorLevel() !== 'none'}).rows; const stale = dialog.kind === 'recover' && dialog.offer && draftIsStale(state, dialog.offer) ? 'The file changed on disk since this draft was saved. ' : ''; const detail = dialog.detail || stale ? [`${foreground(UI_COLORS.subtle)}${truncateText(inert(`${stale}${dialog.detail ?? ''}`.trim()), width)}${RESET}`] : []; - return [`${accent}${truncateText(message, width)}${RESET}`, ...detail, ...actions]; + return [`${accent}${fitFileMessage(message, state.file.title, width)}${RESET}`, ...detail, ...actions]; } if (state.input) return renderControlRows([['Enter', state.input.kind === 'saveas' ? 'save here' : 'find'], ['Esc', 'cancel']], width); return renderControlRows([['Ctrl+S', 'save'], ['Esc', 'close'], ['Ctrl+Z', 'undo'], ['Ctrl+Y', 'redo'], ['Ctrl+F', 'find'], ['Ctrl+C', 'copy'], ['Ctrl+X', 'cut'], @@ -701,6 +701,21 @@ function footerRows(state: EditorState, columns: number): string[] { ...(state.workspace ? [['F2', 'split']] as Array<[string, string]> : [])], width); } +/** + * A dialog message that starts with the file's path keeps what it says when the path is long: the path gives way from + * the left, keeping its file name, so "has unsaved changes." or "changed on disk" is never the part that is cut. + */ +function fitFileMessage(message: string, title: string, width: number): string { + if (displayWidth(message) <= width || !title || !message.startsWith(title)) return truncateText(message, width); + const rest = message.slice(title.length); + const room = width - displayWidth(rest); + if (room < 8) return truncateText(message, width); + const parts = graphemes(title); + let kept = ''; + for (let index = parts.length - 1; index >= 0 && displayWidth(`…${parts[index]}${kept}`) <= room; index -= 1) kept = parts[index] + kept; + return `…${kept}${rest}`; +} + export function renderEditor(state: EditorState, columns: number, height: number): string[] { if (state.review) return renderReview(state, columns, height); if (state.revealPending) { state.revealPending = false; reveal(state, columns, height); } diff --git a/tests/textEditor.test.ts b/tests/textEditor.test.ts index 5701f594..1175b276 100644 --- a/tests/textEditor.test.ts +++ b/tests/textEditor.test.ts @@ -319,6 +319,31 @@ test('panel: Esc with unsaved changes asks; Discard, Save and Keep editing do wh } finally { rmSync(root, {recursive: true, force: true}); } }); +test('panel: a long file path gives way from the left, so the unsaved and on-disk questions stay readable', async () => { + const root = scratch(); + try { + // Longer than an 80-column panel on its own, whatever the temp folder's length. + const folder = join(root, 'a-deliberately-long-folder-name-that-fills-the-panel-by-itself-alone'); + mkdirSync(folder); + writeFileSync(join(folder, 'notes.txt'), 'b\n'); + const state = createEditor(await opened(join(folder, 'notes.txt'))); + assert.ok(displayWidth(state.file.title) > 80); + press(state, 'x'); + press(state, {kind: 'escape'}); + assert.equal(state.dialog?.kind, 'unsaved'); + for (const columns of [80, 40]) { + const rows = renderEditor(state, columns, 24).map(row => stripAnsi(row)); + assert.ok(rows.every(row => displayWidth(row) <= columns), `fits ${columns} columns`); + assert.ok(rows.some(row => /…[^\s]*\/notes\.txt has unsaved changes\.$/u.test(row.trimEnd())), `the question is whole at ${columns} columns`); + } + state.dialog = {kind: 'conflict', focus: 0, conflict: 'changed', reason: `${state.file.title} changed on disk since you opened it.`}; + const rows = renderEditor(state, 80, 24).map(row => stripAnsi(row)); + assert.ok(rows.some(row => /…[^\s]*\/notes\.txt changed on disk since you opened it\.$/u.test(row.trimEnd()))); + state.dialog = {kind: 'conflict', focus: 0, conflict: 'changed'}; + assert.ok(renderEditor(state, 80, 24).map(row => stripAnsi(row)).some(row => /…[^\s]*\/notes\.txt changed on disk\.$/u.test(row.trimEnd()))); + } finally { rmSync(root, {recursive: true, force: true}); } +}); + test('panel: hostile file content is drawn inert, the caret and selection are visible without color, and it fits narrow panels', async () => { const root = scratch(); try {