Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- **`/modules explain` and `/modules timings`** ([#365](https://github.com/raiseCatError/notMyShell/issues/365)) write a plain-text report into the transcript: each configured module's surface, whether it is on screen and why (including when the Context Rail or Status Strip left it out, or shortened it, to fit the width in the last frame), and its sources' state, age and last collection time; or the slowest last collections. They read what the Context Engine already has and collect nothing.
- **Five more bundled theme families** ([#401](https://github.com/raiseCatError/notMyShell/issues/401)): Ayu (Dark, Mirage, Light), Kanagawa (Wave, Dragon, Lotus), Everforest (Dark, Light), Flexoki (Dark, Light) and Night Owl (Night Owl, Light Owl), with colors copied exactly from each project's MIT-licensed source at a recorded revision. They theme NMSh's own prompt fills and chrome only; the terminal's own colors and configuration are never changed. Existing theme ids and saved choices are unchanged, and Lavender stays the default. Theme Studio's Built-in list gains `/` to filter by name, family, or `dark`/`light`.
- **`/track`** keeps one live result of a finite command in the transcript, refreshed in place ([#393](https://github.com/raiseCatError/notMyShell/issues/393)). `/track df -h` or `/track --every 5s -- docker system df` writes the command once, then shows a status row (`live · every 5s · updated 13:49:25 · exit 0`, a failing exit, a timeout, paused, stale, refreshing) and the latest result under it, with a stable height. The shell stays free; nothing accumulates per refresh, and find, selection and `/copy` never include the live rows. `/track copy` copies the latest result, `/track refresh`, `pause`, `resume` and `stop` control it, and stopping leaves the last result in the transcript once. Samples run `sh -c` from the starting directory with your shell's PATH (not its aliases or functions), never overlap, and are bounded (200 kB, a timeout, at most 8 trackers); destructive and unknown commands are refused or need a Yes, as for `/watch`. Trackers stop with the window that started them. `/watch` keeps its manager and its behavior; both share one scheduler, which gains stricter interval parsing, refusals for substitutions, background jobs and heredocs, UTF-8-safe bounded capture, visible timeouts and a stop grace period (from draft #394).
- **From `/view` to your own editor** ([#366](https://github.com/raiseCatError/notMyShell/issues/366)). In `/view` of a file, `o` opens it at the first line on screen through the same bridge as `/open`: Zed or VS Code (around NMSh, or chosen in Open with) opens beside NMSh, and a terminal editor from `$VISUAL`/`$EDITOR` gets its command placed in the composer for you to run, never run for you. A draft already in the composer is never replaced, and with no editor known the viewer says how to set one. A file `/view` shows only in part, or cannot decode, now names this way to the rest.

### Fixed

Expand Down
20 changes: 20 additions & 0 deletions src/app/TerminalApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1643,6 +1643,7 @@ export class TerminalApp {
const action = viewerKey(viewer, key, columns, rows - 4, this.viewerLook());
if (action?.kind === 'close') { this.viewer = undefined; this.returnFromPanel(); }
else if (action?.kind === 'edit') { this.viewer = undefined; this.openTextEditor(action.path, '/edit', viewer); }
else if (action?.kind === 'open') void this.openFromViewer(viewer, action);
else if (action?.kind === 'copy') {
void this.copyText(action.text, 'Copied the document').then(note => { if (note && this.viewer === viewer) { viewer.note = note; this.render(); } });
}
Expand Down Expand Up @@ -8562,6 +8563,25 @@ export class TerminalApp {
report(failure ? `Could not start ${action.label}: ${failure}` : `Opened in ${action.label}.`, failure ? ERROR : INFO);
}

/**
* `o` in /view: the file shown goes to the person's editor through the /open bridge. A GUI editor opens beside NMSh
* and the viewer stays; a terminal editor's command goes in the composer for the person to run, so the viewer
* closes, and a draft already there is never replaced.
*/
private async openFromViewer(viewer: ViewerState, target: {path: string; line?: number}): Promise<void> {
const action = this.hostActions().openFile({path: target.path, ...(target.line ? {line: target.line} : {})});
const say = (note: string) => { if (this.viewer === viewer) { viewer.note = note; this.render(); } };
if (action.kind === 'unsupported') return say(action.reason);
if (action.kind === 'compose') {
if (this.editor.text.trim()) return say(`The composer has a draft. Clear it, then press o to put the ${action.label} command there.`);
this.viewer = undefined;
this.returnFromPanel();
return this.performHostAction(action, (message, style) => { this.output.addFrontendInteraction('/view', message, style); this.render(); });
}
const failure = await runHostAction(action);
say(failure ? `Could not start ${action.label}: ${failure}` : `Opened in ${action.label}.`);
}

/** Changes whenever presented rows may have changed; matches are recomputed only then. */
private findGeneration(wrapped: readonly WrappedRow[], columns: number): string {
const filter = this.output.activeFilter;
Expand Down
2 changes: 1 addition & 1 deletion src/ask/concepts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,7 @@ export const CONCEPTS: readonly Concept[] = [
description: '/compare shows what changed between two command outputs (by default the latest and the previous run of the same command; /compare N M picks them by /copy number). Also Actions → Compare output…. Added, removed and changed lines; c copies the diff, r a comparison report.'},
{id: 'view', label: 'View a file or output', support: 'actionable', open: '/view', covers: ['/view'],
aliases: ['view file', 'view a file', 'read a file', 'open a file read only', 'view output', 'read markdown', 'preview markdown', 'view log', 'pager'],
description: '/view opens a read-only native viewer: /view README.md for a file (Markdown renders; m shows the source), /view N for completed output N (numbered as /copy), /view alone for the latest output. Also Actions → View output. Find with /, n/N, go to a line with :, w wraps, l numbers, c copies the whole text. less, bat and Glow still work as usual.'},
description: '/view opens a read-only native viewer: /view README.md for a file (Markdown renders; m shows the source), /view N for completed output N (numbered as /copy), /view alone for the latest output. Also Actions → View output. Find with /, n/N, go to a line with :, w wraps, l numbers, c copies the whole text; for a file, e edits it in /edit and o opens it in your own editor at the line shown (as /open does). less, bat and Glow still work as usual.'},
{id: 'edit-file', label: 'Edit a small text file', support: 'actionable', open: '/edit', covers: ['/edit'],
aliases: ['edit a file', 'edit file', 'edit text file', 'change a config file', 'quick edit', 'small editor', 'native editor', 'edit notes'],
description: '/edit notes.md opens a small text file (up to 1 MiB, your own, UTF-8) in NMSh\'s editor: type, select with Shift, Ctrl+Z undo, Ctrl+Y redo, Ctrl+F find, Ctrl+C/Ctrl+X copy and cut, Ctrl+S saves (atomically; if the file changed on disk you choose Overwrite, Reload or Keep editing), Esc closes and asks about unsaved changes. e in /view edits the file shown. vim, nano, micro and your $EDITOR still work as usual.'},
Expand Down
8 changes: 6 additions & 2 deletions src/viewer/ViewerPanel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,9 @@ export interface ViewerState {
markdown?: {width: number; rows: MarkdownRow[]};
}

export type ViewerAction = {kind: 'close'} | {kind: 'copy'; text: string; lines: number} | {kind: 'edit'; path: string};
export type ViewerAction = {kind: 'close'} | {kind: 'copy'; text: string; lines: number} | {kind: 'edit'; path: string}
/** The file shown, for the person's own editor (the /open bridge); line is the first line on screen, when it is one. */
| {kind: 'open'; path: string; line?: number};

export interface ViewerLook {syntax?: SyntaxSgr; hyperlinks?: boolean}

Expand Down Expand Up @@ -401,6 +403,8 @@ export function viewerKey(state: ViewerState, key: Key, columns: number, height:
else if (text === 'N' && state.query) step(state, -1, width, body, look);
else if (text === 'c') return {kind: 'copy', text: state.document.text, lines: state.document.lines.length};
else if (text === 'e' && state.document.origin.kind === 'file') return {kind: 'edit', path: state.document.origin.path};
// Rendered Markdown rows are not source lines: the file then opens at its start.
else if (text === 'o' && state.document.origin.kind === 'file') return {kind: 'open', path: state.document.origin.path, ...(state.rendered ? {} : {line: state.top + 1})};
return undefined;
}

Expand Down Expand Up @@ -438,7 +442,7 @@ function controls(state: ViewerState, columns: number): string[] {
['↑↓', 'scroll'], ['/', 'find'], ...(state.query ? [['n/N', 'next/previous'] as const] : []), [':', 'line'],
...(state.document.format === 'markdown' ? [['m', state.rendered ? 'source' : 'rendered'] as const] : []),
...(!state.rendered ? [['w', state.wrap ? 'no wrap' : 'wrap'] as const, ['l', 'numbers'] as const] : []),
['c', 'copy all'], ...(state.document.origin.kind === 'file' ? [['e', 'edit'] as const] : []), ['Esc', 'close'],
['c', 'copy all'], ...(state.document.origin.kind === 'file' ? [['e', 'edit'] as const, ['o', 'open in editor'] as const] : []), ['Esc', 'close'],
], width);
}

Expand Down
6 changes: 3 additions & 3 deletions src/viewer/document.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,11 +144,11 @@ export async function loadFileDocument(input: string, cwd: string, options: {hom
// Never split a UTF-8 sequence: step back over continuation bytes.
while (cut > 0 && (bytes[cut]! & 0xc0) === 0x80) cut -= 1;
bytes = bytes.subarray(0, cut);
notes.push(`Showing the first ${formatBytes(cut)} of ${formatBytes(info.size)}; open it with less or an editor for the rest.`);
notes.push(`Showing the first ${formatBytes(cut)} of ${formatBytes(info.size)}; press o to open it in your editor, or use less, for the rest.`);
}
const sniff = bytes.subarray(0, SNIFF_BYTES);
if ((sniff[0] === 0xff && sniff[1] === 0xfe) || (sniff[0] === 0xfe && sniff[1] === 0xff)) {
return {ok: false, reason: `${shown} is UTF-16 text, which /view does not decode; try less or an editor.`};
return {ok: false, reason: `${shown} is UTF-16 text, which /view does not decode; try less, or /open it in your editor.`};
}
if (sniff.includes(0)) return {ok: false, reason: `${shown} looks binary (it contains NUL bytes); /view shows text only.`};
if (bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) bytes = bytes.subarray(3);
Expand All @@ -162,7 +162,7 @@ export async function loadFileDocument(input: string, cwd: string, options: {hom
const maxLines = options.maxLines ?? MAX_VIEW_LINES;
if (lines.length > maxLines) {
complete = false;
notes.push(`Showing the first ${maxLines.toLocaleString('en-US')} lines; open it with less or an editor for the rest.`);
notes.push(`Showing the first ${maxLines.toLocaleString('en-US')} lines; press o to open it in your editor, or use less, for the rest.`);
lines = lines.slice(0, maxLines);
text = lines.join(lineEnding === 'crlf' ? '\r\n' : '\n');
}
Expand Down
34 changes: 33 additions & 1 deletion tests/viewer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ test('files: only regular, decodable text is read, within limits, and every refu
writeFileSync(join(root, 'bin'), Buffer.from([0x7f, 0x45, 0x4c, 0x46, 0, 1, 2]));
assert.match((await loadFileDocument(join(root, 'bin'), '/') as {reason: string}).reason, /binary/u);
writeFileSync(join(root, 'utf16.txt'), Buffer.from([0xff, 0xfe, 0x61, 0x00]));
assert.match((await loadFileDocument(join(root, 'utf16.txt'), '/') as {reason: string}).reason, /UTF-16/u);
assert.match((await loadFileDocument(join(root, 'utf16.txt'), '/') as {reason: string}).reason, /UTF-16.*\/open it in your editor/u);

if (spawnSync('mkfifo', [join(root, 'pipe')]).status === 0) {
// Opening a FIFO must not wait for a writer: it is refused at once.
Expand All @@ -77,10 +77,12 @@ test('files: only regular, decodable text is read, within limits, and every refu
assert.equal(cut.text, 'é'.repeat(25));
assert.equal(cut.complete, false);
assert.match(cut.notes[0]!, /Showing the first/u);
assert.match(cut.notes[0]!, /press o to open it in your editor/u, 'the note names the way to the rest');
writeFileSync(join(root, 'many.txt'), Array.from({length: 50}, (_, index) => `line ${index}`).join('\n'));
const many = await load(join(root, 'many.txt'), {maxLines: 10});
assert.equal(many.lines.length, 10);
assert.equal(many.complete, false);
assert.match(many.notes[0]!, /press o to open it in your editor/u);

symlinkSync(join(root, 'a.ts'), join(root, 'link.ts'));
const linked = await load(join(root, 'link.ts'));
Expand Down Expand Up @@ -136,6 +138,36 @@ test('Markdown files render, switch to source with m, and output whose command i
} finally { rmSync(root, {recursive: true, force: true}); }
});

test('o hands a file to the person\'s editor at the first line on screen; output has no file and offers nothing', async () => {
const root = scratch();
try {
const path = join(root, 'notes.txt');
writeFileSync(path, Array.from({length: 200}, (_, index) => `line ${index + 1}`).join('\n'));
const viewer = createViewer(await load(path));
draw(viewer);
assert.deepEqual(press(viewer, ['o']), {kind: 'open', path, line: 1});
press(viewer, [':', '4', '2', {kind: 'enter'}]);
assert.deepEqual(press(viewer, ['o']), {kind: 'open', path, line: 42}, 'the line at the top of the view');
assert.match(shown(draw(viewer)).join('\n'), /o open in editor/u, 'the footer offers it for files');

// Rendered Markdown rows are not source lines: the file opens at its start; the source view keeps the line.
writeFileSync(join(root, 'README.md'), `# Title\n\n${'text\n\n'.repeat(80)}`);
const markdown = createViewer(await load(join(root, 'README.md')));
draw(markdown);
press(markdown, [{kind: 'pageDown'}]);
assert.deepEqual(press(markdown, ['o']), {kind: 'open', path: join(root, 'README.md')});
press(markdown, ['m', 'G']);
draw(markdown);
const source = press(markdown, ['o']);
assert.equal(source?.kind, 'open');
assert.ok(source?.kind === 'open' && source.line === markdown.top + 1 && source.line > 1);

const output = createViewer(outputDocument({command: 'seq 3', output: '1\n2\n3\n'}, 1));
assert.equal(press(output, ['o']), undefined, 'stored output is not a file');
assert.doesNotMatch(shown(draw(output)).join('\n'), /open in editor/u);
} finally { rmSync(root, {recursive: true, force: true}); }
});

test('search finds, counts and steps through matches, wrapping with a note; Esc clears it before closing', () => {
const document = outputDocument({command: 'log', output: Array.from({length: 300}, (_, index) => index % 50 === 7 ? `ERROR at ${index}` : `ok ${index}`).join('\n')}, 1);
const viewer = createViewer(document);
Expand Down
Loading
Loading