Skip to content

Commit

Permalink
Tab complete file and directory names
Browse files Browse the repository at this point in the history
  • Loading branch information
ianthomas23 committed Jul 30, 2024
1 parent 54afdb0 commit 3f634b6
Show file tree
Hide file tree
Showing 2 changed files with 157 additions and 3 deletions.
49 changes: 46 additions & 3 deletions src/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,22 +247,65 @@ export class Shell {
const parsed = parse(text, false);
const [lastToken, isCommand] =
parsed.length > 0 ? parsed[parsed.length - 1].lastToken() : [null, true];
const lookup = lastToken?.value ?? '';
let lookup = lastToken?.value ?? '';

let possibles: string[] = [];
//let prefix = '';
if (isCommand) {
const commandMatches = CommandRegistry.instance().match(lookup);
const aliasMatches = this._aliases.match(lookup);
// Combine, removing duplicates, and sort.
possibles = [...new Set([...commandMatches, ...aliasMatches])].sort();
} else {
// Is filename, not yet implemented.
// Is filename.
const { FS } = this._fileSystem!;
const analyze = FS.analyzePath(lookup, false);
if (!analyze.parentExists) {
return;
}

const initialLookup = lookup;
lookup = analyze.name;
const { exists } = analyze;
if (exists && !FS.isDir(FS.stat(analyze.path).mode)) {
// Exactly matches a filename.
possibles = [lookup];
} else {
const lookupPath = exists ? analyze.path : analyze.parentPath;
possibles = FS.readdir(lookupPath);

if (exists) {
const wantDot =
initialLookup === '.' ||
initialLookup === '..' ||
initialLookup.endsWith('/.') ||
initialLookup.endsWith('/..');
if (wantDot) {
possibles = possibles.filter((path: string) => path.startsWith('.'));
} else {
possibles = possibles.filter((path: string) => !path.startsWith('.'));
if (!initialLookup.endsWith('/')) {
this._currentLine += '/';
}
}
} else {
possibles = possibles.filter((path: string) => path.startsWith(lookup));
}

// Directories are displayed with appended /
possibles = possibles.map((path: string) =>
FS.isDir(FS.stat(lookupPath + '/' + path).mode) ? path + '/' : path
);
}
}

if (possibles.length === 0) {
return;
} else if (possibles.length === 1) {
const extra = possibles[0].slice(lookup.length) + ' ';
let extra = possibles[0].slice(lookup.length);
if (!extra.endsWith('/')) {
extra += ' ';
}
this._currentLine += extra;
await this.output(extra);
return;
Expand Down
111 changes: 111 additions & 0 deletions test/tests/shell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,117 @@ test.describe('Shell', () => {
});
});

test.describe('input tab complete filenames', () => {
test('should do nothing with unrecognised filename', async ({ page }) => {
expect(await shellInputsSimple(page, ['l', 's', ' ', 'z', '\t'])).toEqual('ls z');
});

test('should show tab completion options', async ({ page }) => {
const output = await shellInputsSimple(page, ['l', 's', ' ', 'f', 'i', 'l', 'e', '\t']);
expect(output).toMatch(/^ls file\r\nfile1 {2}file2\r\n/);
});

test('should add common startsWith', async ({ page }) => {
expect(await shellInputsSimple(page, ['l', 's', ' ', 'f', '\t'])).toEqual('ls file');
});

test('should complete single filename, adding trailing space', async ({ page }) => {
const output = await shellInputsSimple(page, ['l', 's', ' ', 'f', 'i', 'l', 'e', '1', '\t']);
expect(output).toEqual('ls file1 ');
});

test('should complete single directory, adding trailing slash', async ({ page }) => {
const output = await shellInputsSimple(page, ['l', 's', ' ', 'd', '\t']);
expect(output).toEqual('ls dirA/');
});

test('should list contents if match directory with trailing slash', async ({ page }) => {
const output = await shellInputsSimple(page, [
'l',
's',
' ',
'/',
'd',
'r',
'i',
'v',
'e',
'/',
'\t'
]);
expect(output).toMatch(/^ls \/drive\/\r\nfile1 {2}file2 {2}dirA/);
expect(output).toMatch(/ls \/drive\/$/);
});

test('should list contents if match directory without trailing slash', async ({ page }) => {
const output = await shellInputsSimple(page, [
'l',
's',
' ',
'/',
'd',
'r',
'i',
'v',
'e',
'\t'
]);
expect(output).toMatch(/^ls \/drive\r\nfile1 {2}file2 {2}dirA/);
expect(output).toMatch(/ls \/drive\/$/);
});

test('should support . for current directory', async ({ page }) => {
const output = await shellInputsSimple(page, ['l', 's', ' ', '.', '\t']);
expect(output).toMatch(/^ls .\r\n.\/ {2}..\//);
expect(output).toMatch(/ls .$/);
});

test('should support . for current directory 2', async ({ page }) => {
const output = await shellInputsSimple(page, [
'l',
's',
' ',
'.',
'/',
'f',
'i',
'l',
'e',
'\t'
]);
expect(output).toMatch(/^ls .\/file\r\nfile1 {2}file2\r\n/);
expect(output).toMatch(/ls .\/file$/);
});

test('should support .. for parent directory', async ({ page }) => {
const output = await shellInputsSimple(page, ['l', 's', ' ', '.', '.', '/', 'd', 'r', '\t']);
expect(output).toEqual('ls ../drive/');
});

test('should show dot files/directories', async ({ page }) => {
const output = await page.evaluate(async () => {
const { shell, output, FS } = await globalThis.cockle.shell_setup_simple();
FS.mkdir('.adir');
FS.writeFile('.afile1', '');
FS.writeFile('.afile2', '');
await shell.inputs(['l', 's', ' ', '.', '\t']);
const ret = [output.text];
output.clear();

await shell.inputs(['l', 's', ' ', '.', 'a', '\t']);
ret.push(output.text);
output.clear();

await shell.inputs(['l', 's', ' ', '.', 'a', 'f', '\t']);
ret.push(output.text);
return ret;
});
expect(output[0]).toMatch(/^ls .\r\n.\/ {2}..\/ {2}.adir\/ {2}.afile1 {2}.afile2\r\n/);
expect(output[1]).toMatch(/^ls .a\r\n.adir\/ {2}.afile1 {2}.afile2\r\n/);
expect(output[2]).toEqual('ls .afile');
});
});

test.describe('setSize', () => {
test('should set envVars', async ({ page }) => {
const output = await page.evaluate(async () => {
Expand Down

0 comments on commit 3f634b6

Please sign in to comment.