Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca585447ff | ||
|
|
71301861c0 | ||
|
|
2ce1dc43fe |
@@ -17,6 +17,7 @@
|
|||||||
"@xterm/xterm": "^5.5.0"
|
"@xterm/xterm": "^5.5.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"happy-dom": "^20.11.6",
|
||||||
"typescript": "^5.6.3",
|
"typescript": "^5.6.3",
|
||||||
"vite": "^5.4.10",
|
"vite": "^5.4.10",
|
||||||
"vitest": "^2.1.9"
|
"vitest": "^2.1.9"
|
||||||
|
|||||||
Generated
+93
-11
@@ -15,15 +15,18 @@ importers:
|
|||||||
specifier: ^5.5.0
|
specifier: ^5.5.0
|
||||||
version: 5.5.0
|
version: 5.5.0
|
||||||
devDependencies:
|
devDependencies:
|
||||||
|
happy-dom:
|
||||||
|
specifier: ^20.11.6
|
||||||
|
version: 20.11.6
|
||||||
typescript:
|
typescript:
|
||||||
specifier: ^5.6.3
|
specifier: ^5.6.3
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
vite:
|
vite:
|
||||||
specifier: ^5.4.10
|
specifier: ^5.4.10
|
||||||
version: 5.4.21
|
version: 5.4.21(@types/node@26.2.0)
|
||||||
vitest:
|
vitest:
|
||||||
specifier: ^2.1.9
|
specifier: ^2.1.9
|
||||||
version: 2.1.9
|
version: 2.1.9(@types/node@26.2.0)(happy-dom@20.11.6)
|
||||||
|
|
||||||
packages:
|
packages:
|
||||||
|
|
||||||
@@ -296,6 +299,15 @@ packages:
|
|||||||
'@types/estree@1.0.8':
|
'@types/estree@1.0.8':
|
||||||
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
||||||
|
|
||||||
|
'@types/node@26.2.0':
|
||||||
|
resolution: {integrity: sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==}
|
||||||
|
|
||||||
|
'@types/whatwg-mimetype@3.0.2':
|
||||||
|
resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==}
|
||||||
|
|
||||||
|
'@types/ws@8.18.1':
|
||||||
|
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
|
||||||
|
|
||||||
'@vitest/expect@2.1.9':
|
'@vitest/expect@2.1.9':
|
||||||
resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==}
|
resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==}
|
||||||
|
|
||||||
@@ -337,6 +349,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
buffer-image-size@0.6.4:
|
||||||
|
resolution: {integrity: sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==}
|
||||||
|
engines: {node: '>=4.0'}
|
||||||
|
|
||||||
cac@6.7.14:
|
cac@6.7.14:
|
||||||
resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==}
|
resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -362,6 +378,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==}
|
resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
entities@7.0.1:
|
||||||
|
resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
|
||||||
|
engines: {node: '>=0.12'}
|
||||||
|
|
||||||
es-module-lexer@1.7.0:
|
es-module-lexer@1.7.0:
|
||||||
resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
|
resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
|
||||||
|
|
||||||
@@ -382,6 +402,10 @@ packages:
|
|||||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||||
os: [darwin]
|
os: [darwin]
|
||||||
|
|
||||||
|
happy-dom@20.11.6:
|
||||||
|
resolution: {integrity: sha512-Hldbg8AdAa5a5oDcZpjqnGitp7JB0hqWmfv/8qr+kft4vzSD8BHsbdRfzYvL/0QcbKcURC/yyoygbeDQarPvYg==}
|
||||||
|
engines: {node: '>=20.0.0'}
|
||||||
|
|
||||||
loupe@3.2.1:
|
loupe@3.2.1:
|
||||||
resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
|
resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
|
||||||
|
|
||||||
@@ -451,6 +475,9 @@ packages:
|
|||||||
engines: {node: '>=14.17'}
|
engines: {node: '>=14.17'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
undici-types@8.3.0:
|
||||||
|
resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==}
|
||||||
|
|
||||||
vite-node@2.1.9:
|
vite-node@2.1.9:
|
||||||
resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==}
|
resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==}
|
||||||
engines: {node: ^18.0.0 || >=20.0.0}
|
engines: {node: ^18.0.0 || >=20.0.0}
|
||||||
@@ -512,11 +539,27 @@ packages:
|
|||||||
jsdom:
|
jsdom:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
whatwg-mimetype@3.0.0:
|
||||||
|
resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
why-is-node-running@2.3.0:
|
why-is-node-running@2.3.0:
|
||||||
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
|
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
ws@8.21.3:
|
||||||
|
resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==}
|
||||||
|
engines: {node: '>=10.0.0'}
|
||||||
|
peerDependencies:
|
||||||
|
bufferutil: ^4.0.1
|
||||||
|
utf-8-validate: '>=5.0.2'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
bufferutil:
|
||||||
|
optional: true
|
||||||
|
utf-8-validate:
|
||||||
|
optional: true
|
||||||
|
|
||||||
snapshots:
|
snapshots:
|
||||||
|
|
||||||
'@esbuild/aix-ppc64@0.21.5':
|
'@esbuild/aix-ppc64@0.21.5':
|
||||||
@@ -667,6 +710,16 @@ snapshots:
|
|||||||
|
|
||||||
'@types/estree@1.0.8': {}
|
'@types/estree@1.0.8': {}
|
||||||
|
|
||||||
|
'@types/node@26.2.0':
|
||||||
|
dependencies:
|
||||||
|
undici-types: 8.3.0
|
||||||
|
|
||||||
|
'@types/whatwg-mimetype@3.0.2': {}
|
||||||
|
|
||||||
|
'@types/ws@8.18.1':
|
||||||
|
dependencies:
|
||||||
|
'@types/node': 26.2.0
|
||||||
|
|
||||||
'@vitest/expect@2.1.9':
|
'@vitest/expect@2.1.9':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@vitest/spy': 2.1.9
|
'@vitest/spy': 2.1.9
|
||||||
@@ -674,13 +727,13 @@ snapshots:
|
|||||||
chai: 5.3.3
|
chai: 5.3.3
|
||||||
tinyrainbow: 1.2.0
|
tinyrainbow: 1.2.0
|
||||||
|
|
||||||
'@vitest/mocker@2.1.9(vite@5.4.21)':
|
'@vitest/mocker@2.1.9(vite@5.4.21(@types/node@26.2.0))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@vitest/spy': 2.1.9
|
'@vitest/spy': 2.1.9
|
||||||
estree-walker: 3.0.3
|
estree-walker: 3.0.3
|
||||||
magic-string: 0.30.21
|
magic-string: 0.30.21
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
vite: 5.4.21
|
vite: 5.4.21(@types/node@26.2.0)
|
||||||
|
|
||||||
'@vitest/pretty-format@2.1.9':
|
'@vitest/pretty-format@2.1.9':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -715,6 +768,10 @@ snapshots:
|
|||||||
|
|
||||||
assertion-error@2.0.1: {}
|
assertion-error@2.0.1: {}
|
||||||
|
|
||||||
|
buffer-image-size@0.6.4:
|
||||||
|
dependencies:
|
||||||
|
'@types/node': 26.2.0
|
||||||
|
|
||||||
cac@6.7.14: {}
|
cac@6.7.14: {}
|
||||||
|
|
||||||
chai@5.3.3:
|
chai@5.3.3:
|
||||||
@@ -733,6 +790,8 @@ snapshots:
|
|||||||
|
|
||||||
deep-eql@5.0.2: {}
|
deep-eql@5.0.2: {}
|
||||||
|
|
||||||
|
entities@7.0.1: {}
|
||||||
|
|
||||||
es-module-lexer@1.7.0: {}
|
es-module-lexer@1.7.0: {}
|
||||||
|
|
||||||
esbuild@0.21.5:
|
esbuild@0.21.5:
|
||||||
@@ -770,6 +829,19 @@ snapshots:
|
|||||||
fsevents@2.3.3:
|
fsevents@2.3.3:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
happy-dom@20.11.6:
|
||||||
|
dependencies:
|
||||||
|
'@types/node': 26.2.0
|
||||||
|
'@types/whatwg-mimetype': 3.0.2
|
||||||
|
'@types/ws': 8.18.1
|
||||||
|
buffer-image-size: 0.6.4
|
||||||
|
entities: 7.0.1
|
||||||
|
whatwg-mimetype: 3.0.0
|
||||||
|
ws: 8.21.3
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- bufferutil
|
||||||
|
- utf-8-validate
|
||||||
|
|
||||||
loupe@3.2.1: {}
|
loupe@3.2.1: {}
|
||||||
|
|
||||||
magic-string@0.30.21:
|
magic-string@0.30.21:
|
||||||
@@ -843,13 +915,15 @@ snapshots:
|
|||||||
|
|
||||||
typescript@5.9.3: {}
|
typescript@5.9.3: {}
|
||||||
|
|
||||||
vite-node@2.1.9:
|
undici-types@8.3.0: {}
|
||||||
|
|
||||||
|
vite-node@2.1.9(@types/node@26.2.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
cac: 6.7.14
|
cac: 6.7.14
|
||||||
debug: 4.4.3
|
debug: 4.4.3
|
||||||
es-module-lexer: 1.7.0
|
es-module-lexer: 1.7.0
|
||||||
pathe: 1.1.2
|
pathe: 1.1.2
|
||||||
vite: 5.4.21
|
vite: 5.4.21(@types/node@26.2.0)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@types/node'
|
- '@types/node'
|
||||||
- less
|
- less
|
||||||
@@ -861,18 +935,19 @@ snapshots:
|
|||||||
- supports-color
|
- supports-color
|
||||||
- terser
|
- terser
|
||||||
|
|
||||||
vite@5.4.21:
|
vite@5.4.21(@types/node@26.2.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
esbuild: 0.21.5
|
esbuild: 0.21.5
|
||||||
postcss: 8.5.15
|
postcss: 8.5.15
|
||||||
rollup: 4.60.4
|
rollup: 4.60.4
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
|
'@types/node': 26.2.0
|
||||||
fsevents: 2.3.3
|
fsevents: 2.3.3
|
||||||
|
|
||||||
vitest@2.1.9:
|
vitest@2.1.9(@types/node@26.2.0)(happy-dom@20.11.6):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@vitest/expect': 2.1.9
|
'@vitest/expect': 2.1.9
|
||||||
'@vitest/mocker': 2.1.9(vite@5.4.21)
|
'@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@26.2.0))
|
||||||
'@vitest/pretty-format': 2.1.9
|
'@vitest/pretty-format': 2.1.9
|
||||||
'@vitest/runner': 2.1.9
|
'@vitest/runner': 2.1.9
|
||||||
'@vitest/snapshot': 2.1.9
|
'@vitest/snapshot': 2.1.9
|
||||||
@@ -888,9 +963,12 @@ snapshots:
|
|||||||
tinyexec: 0.3.2
|
tinyexec: 0.3.2
|
||||||
tinypool: 1.1.1
|
tinypool: 1.1.1
|
||||||
tinyrainbow: 1.2.0
|
tinyrainbow: 1.2.0
|
||||||
vite: 5.4.21
|
vite: 5.4.21(@types/node@26.2.0)
|
||||||
vite-node: 2.1.9
|
vite-node: 2.1.9(@types/node@26.2.0)
|
||||||
why-is-node-running: 2.3.0
|
why-is-node-running: 2.3.0
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/node': 26.2.0
|
||||||
|
happy-dom: 20.11.6
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- less
|
- less
|
||||||
- lightningcss
|
- lightningcss
|
||||||
@@ -902,7 +980,11 @@ snapshots:
|
|||||||
- supports-color
|
- supports-color
|
||||||
- terser
|
- terser
|
||||||
|
|
||||||
|
whatwg-mimetype@3.0.0: {}
|
||||||
|
|
||||||
why-is-node-running@2.3.0:
|
why-is-node-running@2.3.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
siginfo: 2.0.0
|
siginfo: 2.0.0
|
||||||
stackback: 0.0.2
|
stackback: 0.0.2
|
||||||
|
|
||||||
|
ws@8.21.3: {}
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("./api.js", () => ({ listDir: vi.fn() }));
|
||||||
|
|
||||||
|
import { attachDirAutocomplete, type DirAutocompleteHandle } from "./dirAutocomplete.js";
|
||||||
|
import { listDir } from "./api.js";
|
||||||
|
|
||||||
|
const listDirMock = vi.mocked(listDir);
|
||||||
|
|
||||||
|
let input: HTMLInputElement;
|
||||||
|
let nextField: HTMLInputElement;
|
||||||
|
let docKeydown: ReturnType<typeof vi.fn>;
|
||||||
|
let ac: DirAutocompleteHandle | undefined;
|
||||||
|
|
||||||
|
function popup(): HTMLUListElement {
|
||||||
|
return document.querySelector(".dir-ac") as HTMLUListElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
function items(): HTMLElement[] {
|
||||||
|
return [...popup().querySelectorAll<HTMLElement>(".dir-ac-item")];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function type(value: string): Promise<void> {
|
||||||
|
input.focus();
|
||||||
|
input.value = value;
|
||||||
|
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
|
await vi.advanceTimersByTimeAsync(100); // past the fetch debounce
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dispatch a cancelable, bubbling keydown on the input; report whether the
|
||||||
|
* default browser behaviour (focus move, form submit) was suppressed. */
|
||||||
|
function press(key: string): { defaultPrevented: boolean } {
|
||||||
|
const evt = new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true });
|
||||||
|
input.dispatchEvent(evt);
|
||||||
|
return { defaultPrevented: evt.defaultPrevented };
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
document.body.replaceChildren();
|
||||||
|
input = document.createElement("input");
|
||||||
|
nextField = document.createElement("input");
|
||||||
|
document.body.append(input, nextField);
|
||||||
|
docKeydown = vi.fn();
|
||||||
|
document.addEventListener("keydown", docKeydown);
|
||||||
|
listDirMock.mockReset().mockResolvedValue({ path: "/tmp", entries: ["proj-a", "proj-b", "other"], truncated: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
ac?.destroy();
|
||||||
|
document.removeEventListener("keydown", docKeydown);
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("dirAutocomplete dismissal", () => {
|
||||||
|
it("Escape closes the popup, keeps the typed value and does not propagate", async () => {
|
||||||
|
ac = attachDirAutocomplete(input);
|
||||||
|
await type("/tmp/pro");
|
||||||
|
expect(popup().hidden).toBe(false);
|
||||||
|
|
||||||
|
const { defaultPrevented } = press("Escape");
|
||||||
|
|
||||||
|
expect(defaultPrevented).toBe(true);
|
||||||
|
expect(popup().hidden).toBe(true);
|
||||||
|
expect(input.value).toBe("/tmp/pro");
|
||||||
|
expect(document.activeElement).toBe(input);
|
||||||
|
expect(docKeydown).not.toHaveBeenCalled(); // a draft pane's Escape handler must not fire
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blur dismisses the popup", async () => {
|
||||||
|
ac = attachDirAutocomplete(input);
|
||||||
|
await type("/tmp/pro");
|
||||||
|
expect(popup().hidden).toBe(false);
|
||||||
|
|
||||||
|
input.blur();
|
||||||
|
await vi.advanceTimersByTimeAsync(150);
|
||||||
|
|
||||||
|
expect(popup().hidden).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not reopen on refocus until the operator types again", async () => {
|
||||||
|
ac = attachDirAutocomplete(input);
|
||||||
|
await type("/tmp/pro");
|
||||||
|
input.blur();
|
||||||
|
await vi.advanceTimersByTimeAsync(150);
|
||||||
|
|
||||||
|
input.focus();
|
||||||
|
await vi.advanceTimersByTimeAsync(150);
|
||||||
|
expect(popup().hidden).toBe(true);
|
||||||
|
|
||||||
|
await type("/tmp/ot");
|
||||||
|
expect(popup().hidden).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("dirAutocomplete keyboard model", () => {
|
||||||
|
it("Tab commits the highlighted entry and lets focus move to the next field", async () => {
|
||||||
|
ac = attachDirAutocomplete(input);
|
||||||
|
await type("/tmp/pro"); // highlight defaults to the first match: proj-a
|
||||||
|
|
||||||
|
nextField.focus();
|
||||||
|
const { defaultPrevented } = press("Tab");
|
||||||
|
|
||||||
|
expect(input.value).toBe("/tmp/proj-a/");
|
||||||
|
expect(defaultPrevented).toBe(false); // native tab navigation proceeds
|
||||||
|
expect(document.activeElement).toBe(nextField); // focus was not yanked back
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Tab with nothing highlighted just moves on", async () => {
|
||||||
|
ac = attachDirAutocomplete(input);
|
||||||
|
await type("/tmp/nope-matches-nothing");
|
||||||
|
expect(popup().hidden).toBe(true);
|
||||||
|
|
||||||
|
const { defaultPrevented } = press("Tab");
|
||||||
|
|
||||||
|
expect(defaultPrevented).toBe(false);
|
||||||
|
expect(input.value).toBe("/tmp/nope-matches-nothing");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("arrows move the highlight and Enter commits it without submitting", async () => {
|
||||||
|
ac = attachDirAutocomplete(input);
|
||||||
|
await type("/tmp/");
|
||||||
|
|
||||||
|
press("ArrowDown");
|
||||||
|
expect(items()[1].classList.contains("is-active")).toBe(true);
|
||||||
|
|
||||||
|
const enter = press("Enter");
|
||||||
|
expect(enter.defaultPrevented).toBe(true); // no form submit
|
||||||
|
expect(input.value).toBe("/tmp/proj-b/");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("dirAutocomplete opening", () => {
|
||||||
|
it("still opens on focus when not previously dismissed", async () => {
|
||||||
|
ac = attachDirAutocomplete(input);
|
||||||
|
input.focus();
|
||||||
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
|
|
||||||
|
expect(popup().hidden).toBe(false);
|
||||||
|
expect(listDirMock).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
+36
-27
@@ -4,15 +4,19 @@
|
|||||||
// per directory, and filters the trailing path segment locally — so typing within a segment is instant and only crossing a `/`
|
// per directory, and filters the trailing path segment locally — so typing within a segment is instant and only crossing a `/`
|
||||||
// hits the network. Keyboard model mirrors `cd dir/sub<tab>`:
|
// hits the network. Keyboard model mirrors `cd dir/sub<tab>`:
|
||||||
//
|
//
|
||||||
// Tab complete the longest common prefix; if one match, accept it (and
|
// Tab commit the highlighted entry and let focus move on; with nothing
|
||||||
// append `/` so the next level loads); if nothing to extend, cycle.
|
// highlighted, plain tab navigation
|
||||||
// ↑/↓ move the highlight
|
// ↑/↓ move the highlight
|
||||||
// Enter accept the highlighted entry (and suppress form submit)
|
// Enter accept the highlighted entry (and suppress form submit)
|
||||||
// Esc close the dropdown
|
// Esc close the dropdown, refocus the input, and swallow the key so it
|
||||||
|
// can't also discard a surrounding draft pane
|
||||||
|
//
|
||||||
|
// Once dismissed (Esc or blur) the popup stays closed until the operator types
|
||||||
|
// again — refocusing alone doesn't reopen it.
|
||||||
//
|
//
|
||||||
// Failures (offline daemon, permission denied) degrade to no suggestions.
|
// Failures (offline daemon, permission denied) degrade to no suggestions.
|
||||||
|
|
||||||
import { longestCommonPrefix, splitPath } from "./paths.js";
|
import { splitPath } from "./paths.js";
|
||||||
import { listDir } from "./api.js";
|
import { listDir } from "./api.js";
|
||||||
|
|
||||||
export interface DirAutocompleteHandle {
|
export interface DirAutocompleteHandle {
|
||||||
@@ -42,6 +46,9 @@ export function attachDirAutocomplete(input: HTMLInputElement): DirAutocompleteH
|
|||||||
let fetchSeq = 0;
|
let fetchSeq = 0;
|
||||||
let debounce: number | undefined;
|
let debounce: number | undefined;
|
||||||
let open = false;
|
let open = false;
|
||||||
|
// Set when the operator dismissed the popup (Esc, blur, outside click); a
|
||||||
|
// refocus must not reopen it — only new typing does.
|
||||||
|
let dismissed = false;
|
||||||
|
|
||||||
|
|
||||||
function close(): void {
|
function close(): void {
|
||||||
@@ -54,6 +61,11 @@ export function attachDirAutocomplete(input: HTMLInputElement): DirAutocompleteH
|
|||||||
window.removeEventListener("resize", reposition);
|
window.removeEventListener("resize", reposition);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function dismiss(): void {
|
||||||
|
dismissed = true;
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
|
||||||
function reposition(): void {
|
function reposition(): void {
|
||||||
const r = input.getBoundingClientRect();
|
const r = input.getBoundingClientRect();
|
||||||
dropdown.style.left = `${r.left}px`;
|
dropdown.style.left = `${r.left}px`;
|
||||||
@@ -132,21 +144,28 @@ export function attachDirAutocomplete(input: HTMLInputElement): DirAutocompleteH
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Accept `name` as the next path segment: complete it and append `/` so the
|
// Accept `name` as the next path segment: complete it and append `/` so the
|
||||||
// child directory's listing loads immediately (the drill-in gesture).
|
// child directory's listing loads on the next interaction (the drill-in
|
||||||
function accept(name: string): void {
|
// gesture). Unlike `accept`, doesn't steal focus — Tab uses this to commit
|
||||||
|
// and move to the next field in one gesture.
|
||||||
|
function commit(name: string): void {
|
||||||
const { dir } = splitPath(input.value);
|
const { dir } = splitPath(input.value);
|
||||||
input.value = `${dir}${name}/`;
|
input.value = `${dir}${name}/`;
|
||||||
input.focus();
|
// Notify other listeners (spawnForm marks the field user-edited).
|
||||||
// Notify other listeners (spawn.ts marks the field user-edited) and load
|
|
||||||
// the child level.
|
|
||||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function accept(name: string): void {
|
||||||
|
commit(name);
|
||||||
|
input.focus();
|
||||||
|
}
|
||||||
|
|
||||||
function onInput(): void {
|
function onInput(): void {
|
||||||
|
dismissed = false;
|
||||||
scheduleRefresh();
|
scheduleRefresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
function onFocus(): void {
|
function onFocus(): void {
|
||||||
|
if (dismissed) return;
|
||||||
refresh();
|
refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,27 +173,17 @@ export function attachDirAutocomplete(input: HTMLInputElement): DirAutocompleteH
|
|||||||
if (e.key === "Escape") {
|
if (e.key === "Escape") {
|
||||||
if (open) {
|
if (open) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
close();
|
// Swallow the key: an Escape that bubbles would also discard the
|
||||||
|
// draft pane hosting this form.
|
||||||
|
e.stopPropagation();
|
||||||
|
dismiss();
|
||||||
|
input.focus();
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (e.key === "Tab") {
|
if (e.key === "Tab") {
|
||||||
if (!open || filtered.length === 0) return; // let focus move normally
|
if (!open || highlight < 0 || highlight >= filtered.length) return; // plain tab navigation
|
||||||
e.preventDefault();
|
commit(filtered[highlight]); // no preventDefault — focus moves to the next field
|
||||||
if (filtered.length === 1) {
|
|
||||||
accept(filtered[0]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const { dir, prefix } = splitPath(input.value);
|
|
||||||
const lcp = longestCommonPrefix(filtered);
|
|
||||||
if (lcp.length > prefix.length) {
|
|
||||||
input.value = `${dir}${lcp}`;
|
|
||||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
||||||
} else {
|
|
||||||
// Nothing to extend — cycle the highlight like a second shell <tab>.
|
|
||||||
highlight = (highlight + 1) % filtered.length;
|
|
||||||
render();
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
@@ -196,7 +205,7 @@ export function attachDirAutocomplete(input: HTMLInputElement): DirAutocompleteH
|
|||||||
|
|
||||||
function onBlur(): void {
|
function onBlur(): void {
|
||||||
// Delay so a dropdown mousedown can complete first.
|
// Delay so a dropdown mousedown can complete first.
|
||||||
window.setTimeout(close, 120);
|
window.setTimeout(dismiss, 120);
|
||||||
}
|
}
|
||||||
|
|
||||||
input.addEventListener("input", onInput);
|
input.addEventListener("input", onInput);
|
||||||
|
|||||||
Reference in New Issue
Block a user