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 {