Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/explorer-backspace-go-up.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@nanocollective/nanocoder': patch
---

Backspace in `/explorer` now goes up a level. On a directory you have opened it collapses that directory; anywhere else it moves the selection to the parent row. It used to collapse the parent of whatever was highlighted instead, so it did nothing on a top-level directory, hid the directory you had just opened on a nested one, and left the selection on an unrelated row. On Windows it did nothing at all, because it split paths on `/` while the tree builds them with `\`. The tree view's help line now lists the shortcut. Closes #1455.
2 changes: 1 addition & 1 deletion docs/features/file-explorer.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ Sometimes you want to browse your project and pick files visually rather than ty
| Enter | Expand/collapse directory or preview file |
| Space | Toggle file/directory selection |
| / | Enter search mode (filters all files including nested) |
| Backspace | Collapse the directory containing the highlighted item |
| Backspace | Collapse the highlighted directory if it is open, otherwise move up to its parent |
| Esc | Exit explorer (selected files are added to input) |

### Preview View
Expand Down
81 changes: 80 additions & 1 deletion source/components/file-explorer/index.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ test('FileExplorer tree view shows navigation help', t => {
const TreeViewHelp = () => (
<Box marginTop={1}>
<Text color="gray" >
Up/Down: navigate | Enter: expand/preview | Space: select | /: search | Esc: done
Up/Down: navigate | Enter: expand/preview | Backspace: up | Space: select | /: search | Esc: done
</Text>
</Box>
);
Expand Down Expand Up @@ -384,6 +384,85 @@ test('FileExplorer keeps the frame while the tree is still loading', t => {
t.regex(output, /Loading file tree/);
});

test('FileExplorer Backspace collapses an open directory, then moves up to the parent', async t => {
// The explorer reads process.cwd(), so run it from a fixture:
// outer/inner/file.txt next to top.txt.
const root = mkdtempSync(join(tmpdir(), 'nanocoder-explorer-'));
mkdirSync(join(root, 'outer', 'inner'), {recursive: true});
writeFileSync(join(root, 'outer', 'inner', 'file.txt'), '');
writeFileSync(join(root, 'top.txt'), '');

const originalCwd = process.cwd();
process.chdir(root);
const {stdin, lastFrame, unmount} = renderWithAllContexts(
<FileExplorer onClose={() => {}} />,
);
t.teardown(() => {
unmount();
process.chdir(originalCwd);
rmSync(root, {recursive: true, force: true});
});

const frame = () => stripAnsi(lastFrame() ?? '');
// Poll rather than sleep: a fixed wait can read the frame before Ink flushes.
const waitUntil = async (predicate: (output: string) => boolean) => {
const start = Date.now();
while (!predicate(frame()) && Date.now() - start < 3000) {
await new Promise(resolve => setTimeout(resolve, 20));
}
};
// Let the previous render's input handler attach before the next key:
// a key sent the moment a row appears is handled by the old closure.
const press = async (key: string) => {
await new Promise(resolve => setTimeout(resolve, 50));
stdin.write(key);
await new Promise(resolve => setTimeout(resolve, 30));
};
const BACKSPACE = '\u007f';
// The status bar prints the highlighted node's path alone on its row.
// buildFileTree joins paths with path.join, so build the expected one the
// same way.
const statusIs = (path: string) => (output: string) =>
output.split('\n').some(line => line.replace(/[│\s]/g, '') === path);
const INNER = join('outer', 'inner');

await waitUntil(output => /outer\//.test(output));
t.regex(frame(), /Backspace: up/);

// Open outer and outer/inner, then highlight the file inside.
await press('\r');
await waitUntil(output => /inner\//.test(output));
await press('\u001b[B');
await waitUntil(statusIs(INNER));
await press('\r');
await waitUntil(output => /file\.txt/.test(output));
await press('\u001b[B');
await waitUntil(output => /file\.txt \(0 B\)/.test(output));

// On a file: move up to its directory, which stays open.
await press(BACKSPACE);
await waitUntil(statusIs(INNER));
t.true(statusIs(INNER)(frame()), frame());
t.regex(frame(), /file\.txt/);

// On an open directory: collapse it, keeping the selection.
await press(BACKSPACE);
await waitUntil(output => !/file\.txt/.test(output));
t.notRegex(frame(), /file\.txt/);
t.true(statusIs(INNER)(frame()), frame());

// On a collapsed directory: move up to the parent...
await press(BACKSPACE);
await waitUntil(statusIs('outer'));
t.true(statusIs('outer')(frame()), frame());

// ...and collapse that one too.
await press(BACKSPACE);
await waitUntil(output => !/inner\//.test(output));
t.notRegex(frame(), /inner\//);
t.true(statusIs('outer')(frame()), frame());
});

test('FileExplorer keeps a valid selection when leaving a search shortens the list', async t => {
// The explorer reads process.cwd(), so run it from a fixture: two
// directories of matching files make a long search result list next to a
Expand Down
24 changes: 16 additions & 8 deletions source/components/file-explorer/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {readFile} from 'node:fs/promises';
import {dirname} from 'node:path';
import {highlight} from 'cli-highlight';
import {Box, Text, useFocus, useInput} from 'ink';
import {useEffect, useMemo, useState} from 'react';
Expand Down Expand Up @@ -263,18 +264,25 @@ export function FileExplorer({onClose}: FileExplorerProps) {
}
};

// Collapse the highlighted directory if it is open, otherwise move up to
// its parent row. Paths come from path.join in buildFileTree, so they use
// the platform separator; dirname splits them the same way.
const handleGoUp = () => {
// Find parent directory and collapse it
if (!selectedNode) return;
const parts = selectedNode.path.split('/');
if (parts.length > 1) {
parts.pop();
const parentPath = parts.join('/');
if (selectedNode.isDirectory && expanded.has(selectedNode.path)) {
setExpanded(prev => {
const next = new Set(prev);
next.delete(parentPath);
next.delete(selectedNode.path);
return next;
});
return;
}
const parentPath = dirname(selectedNode.path);
const parentIndex = filteredList.findIndex(
item => item.node.path === parentPath,
);
if (parentIndex !== -1) {
setSelectedIndex(parentIndex);
}
};

Expand Down Expand Up @@ -366,7 +374,7 @@ export function FileExplorer({onClose}: FileExplorerProps) {
}
} else if (key.backspace || key.delete) {
// Ink reports the usual Backspace byte (\x7f) as `delete`, so accept
// both. Collapses the highlighted node's parent directory.
// both.
handleGoUp();
}
});
Expand Down Expand Up @@ -529,7 +537,7 @@ export function FileExplorer({onClose}: FileExplorerProps) {
<Text color={colors.secondary}>
{searchMode
? 'Type to filter | Backspace: delete | Esc: exit search'
: 'Up/Down: navigate | Enter: expand/preview | Space: select | /: search | Esc: done'}
: 'Up/Down: navigate | Enter: expand/preview | Backspace: up | Space: select | /: search | Esc: done'}
</Text>
</Box>
</Box>
Expand Down
Loading