From 45bce740b6c6c2e710409dd6352daacc0140dd57 Mon Sep 17 00:00:00 2001 From: Dave Date: Mon, 16 Feb 2026 19:44:29 +0000 Subject: [PATCH] Text-completion picker to select a project --- Cargo.lock | 134 +++++++++++++++-- Cargo.toml | 1 + frontend/src/App.tsx | 289 +++++++++++++++++++++++++++++++++++-- frontend/src/api/client.ts | 10 ++ server/Cargo.toml | 2 + server/src/http/io.rs | 19 +++ server/src/io/fs.rs | 12 ++ 7 files changed, 439 insertions(+), 28 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b0ae323..d2935d4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -155,7 +155,7 @@ dependencies = [ "num-traits", "serde", "wasm-bindgen", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -643,6 +643,18 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "homedir" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68df315d2857b2d8d2898be54a85e1d001bbbe0dbb5f8ef847b48dd3a23c4527" +dependencies = [ + "cfg-if", + "nix", + "widestring", + "windows", +] + [[package]] name = "http" version = "1.4.0" @@ -764,7 +776,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core", + "windows-core 0.62.2", ] [[package]] @@ -1156,7 +1168,7 @@ dependencies = [ "libc", "redox_syscall", "smallvec", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -1873,6 +1885,7 @@ dependencies = [ "chrono", "eventsource-stream", "futures", + "homedir", "ignore", "mime_guess", "poem", @@ -2501,6 +2514,12 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + [[package]] name = "wildmatch" version = "2.6.1" @@ -2516,6 +2535,41 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core 0.61.2", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + [[package]] name = "windows-core" version = "0.62.2" @@ -2524,9 +2578,20 @@ checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ "windows-implement", "windows-interface", - "windows-link", - "windows-result", - "windows-strings", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading", ] [[package]] @@ -2551,21 +2616,46 @@ dependencies = [ "syn", ] +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + [[package]] name = "windows-registry" version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" dependencies = [ - "windows-link", - "windows-result", - "windows-strings", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", ] [[package]] @@ -2574,7 +2664,16 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ - "windows-link", + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", ] [[package]] @@ -2583,7 +2682,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" dependencies = [ - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -2619,7 +2718,7 @@ version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -2659,7 +2758,7 @@ version = "0.53.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" dependencies = [ - "windows-link", + "windows-link 0.2.1", "windows_aarch64_gnullvm 0.53.1", "windows_aarch64_msvc 0.53.1", "windows_i686_gnu 0.53.1", @@ -2670,6 +2769,15 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.42.2" diff --git a/Cargo.toml b/Cargo.toml index 9030278..459d1a7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,3 +18,4 @@ walkdir = "2.5.0" eventsource-stream = "0.2.3" rust-embed = "8" mime_guess = "2" +homedir = "0.3.6" diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 81f9774..d56d54a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3,12 +3,36 @@ import { api } from "./api/client"; import { Chat } from "./components/Chat"; import "./App.css"; +function isFuzzyMatch(candidate: string, query: string) { + if (!query) return true; + const lowerCandidate = candidate.toLowerCase(); + const lowerQuery = query.toLowerCase(); + let idx = 0; + for (const char of lowerQuery) { + idx = lowerCandidate.indexOf(char, idx); + if (idx === -1) return false; + idx += 1; + } + return true; +} + function App() { const [projectPath, setProjectPath] = React.useState(null); const [errorMsg, setErrorMsg] = React.useState(null); const [pathInput, setPathInput] = React.useState(""); const [isOpening, setIsOpening] = React.useState(false); const [knownProjects, setKnownProjects] = React.useState([]); + const [homeDir, setHomeDir] = React.useState(null); + + const [suggestion, setSuggestion] = React.useState(null); + const [suggestionTail, setSuggestionTail] = React.useState(""); + const [completionError, setCompletionError] = React.useState( + null, + ); + const [matchList, setMatchList] = React.useState< + { name: string; path: string }[] + >([]); + const [selectedMatch, setSelectedMatch] = React.useState(0); React.useEffect(() => { api @@ -17,6 +41,122 @@ function App() { .catch((error) => console.error(error)); }, []); + React.useEffect(() => { + let active = true; + api + .getHomeDirectory() + .then((home) => { + if (!active) return; + setHomeDir(home); + setPathInput((current) => { + if (current.trim()) { + return current; + } + const initial = home.endsWith("/") ? home : `${home}/`; + return initial; + }); + }) + .catch((error) => { + console.error(error); + }); + + return () => { + active = false; + }; + }, []); + + React.useEffect(() => { + let active = true; + + async function computeSuggestion() { + setCompletionError(null); + setSuggestion(null); + setSuggestionTail(""); + setMatchList([]); + setSelectedMatch(0); + + const trimmed = pathInput.trim(); + if (!trimmed) { + return; + } + + const endsWithSlash = trimmed.endsWith("/"); + let dir = trimmed; + let partial = ""; + + if (!endsWithSlash) { + const idx = trimmed.lastIndexOf("/"); + if (idx >= 0) { + dir = trimmed.slice(0, idx + 1); + partial = trimmed.slice(idx + 1); + } else { + dir = ""; + partial = trimmed; + } + } + + if (!dir) { + if (homeDir) { + dir = homeDir.endsWith("/") ? homeDir : `${homeDir}/`; + } else { + return; + } + } + + const dirForListing = dir === "/" ? "/" : dir.replace(/\/+$/, ""); + const entries = await api.listDirectoryAbsolute(dirForListing); + if (!active) return; + + const matches = entries + .filter((entry) => entry.kind === "dir") + .filter((entry) => isFuzzyMatch(entry.name, partial)) + .sort((a, b) => a.name.localeCompare(b.name)) + .slice(0, 8); + + if (matches.length === 0) { + return; + } + + const basePrefix = dir.endsWith("/") ? dir : `${dir}/`; + const list = matches.map((entry) => ({ + name: entry.name, + path: `${basePrefix}${entry.name}/`, + })); + setMatchList(list); + } + + computeSuggestion().catch((error) => { + console.error(error); + if (!active) return; + setCompletionError( + error instanceof Error + ? error.message + : "Failed to compute suggestion.", + ); + }); + + return () => { + active = false; + }; + }, [pathInput, homeDir]); + + React.useEffect(() => { + if (matchList.length === 0) { + setSuggestion(null); + setSuggestionTail(""); + return; + } + const index = Math.min(selectedMatch, matchList.length - 1); + const next = matchList[index]; + setSuggestion(next.path); + const trimmed = pathInput.trim(); + if (next.path.startsWith(trimmed)) { + setSuggestionTail(next.path.slice(trimmed.length)); + } else { + setSuggestionTail(""); + } + }, [matchList, selectedMatch, pathInput]); + async function openProject(path: string) { if (!path.trim()) { setErrorMsg("Please enter a project path."); @@ -66,7 +206,7 @@ function App() { style={{ padding: "2rem", maxWidth: "800px", margin: "0 auto" }} >

AI Code Assistant

-

Paste a project path to start the Story-Driven Spec Workflow.

+

Paste or complete a project path to start.

{knownProjects.length > 0 && (
@@ -97,21 +237,140 @@ function App() {
)} - setPathInput(event.target.value)} - onKeyDown={(event) => { - if (event.key === "Enter") { - handleOpen(); - } + +
- + > +
+ {pathInput} + {suggestionTail} +
+ setPathInput(event.target.value)} + onKeyDown={(event) => { + if (event.key === "ArrowDown") { + if (matchList.length > 0) { + event.preventDefault(); + setSelectedMatch((prev) => (prev + 1) % matchList.length); + } + } else if (event.key === "ArrowUp") { + if (matchList.length > 0) { + event.preventDefault(); + setSelectedMatch( + (prev) => + (prev - 1 + matchList.length) % matchList.length, + ); + } + } else if (event.key === "Tab") { + if (matchList.length > 0) { + event.preventDefault(); + const next = matchList[selectedMatch]?.path; + if (next) { + setPathInput(next); + } + } + } else if (event.key === "Enter") { + handleOpen(); + } + }} + style={{ + width: "100%", + padding: "10px", + fontFamily: "monospace", + background: "transparent", + position: "relative", + zIndex: 1, + }} + /> + {matchList.length > 0 && ( +
+ {matchList.map((match, index) => { + const isSelected = index === selectedMatch; + return ( + + ); + })} +
+ )} +
+ +
+ +
+ Press Tab to complete the next path segment +
+
+ + {completionError && ( +
+ {completionError} +
+ )}
) : (
diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 93f043d..117aa04 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -153,6 +153,16 @@ export const api = { baseUrl, ); }, + listDirectoryAbsolute(path: string, baseUrl?: string) { + return requestJson( + "/io/fs/list/absolute", + { method: "POST", body: JSON.stringify({ path }) }, + baseUrl, + ); + }, + getHomeDirectory(baseUrl?: string) { + return requestJson("/io/fs/home", {}, baseUrl); + }, searchFiles(query: string, baseUrl?: string) { return requestJson( "/fs/search", diff --git a/server/Cargo.toml b/server/Cargo.toml index c8fbe70..21562e2 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -20,6 +20,8 @@ walkdir = { workspace = true } eventsource-stream = { workspace = true } rust-embed = { workspace = true } mime_guess = { workspace = true } +homedir = { workspace = true } + [dev-dependencies] tempfile = "3" diff --git a/server/src/http/io.rs b/server/src/http/io.rs index 4e1f63f..b97bf20 100644 --- a/server/src/http/io.rs +++ b/server/src/http/io.rs @@ -67,6 +67,25 @@ impl IoApi { Ok(Json(entries)) } + /// List files and folders at an absolute path (not scoped to the project root). + #[oai(path = "/io/fs/list/absolute", method = "post")] + async fn list_directory_absolute( + &self, + payload: Json, + ) -> OpenApiResult>> { + let entries = io_fs::list_directory_absolute(payload.0.path) + .await + .map_err(bad_request)?; + Ok(Json(entries)) + } + + /// Get the user's home directory. + #[oai(path = "/io/fs/home", method = "get")] + async fn get_home_directory(&self) -> OpenApiResult> { + let home = io_fs::get_home_directory().map_err(bad_request)?; + Ok(Json(home)) + } + /// Search the currently open project for files containing the provided query string. #[oai(path = "/io/search", method = "post")] async fn search_files( diff --git a/server/src/io/fs.rs b/server/src/io/fs.rs index 75db80c..2761b03 100644 --- a/server/src/io/fs.rs +++ b/server/src/io/fs.rs @@ -9,6 +9,13 @@ const KEY_LAST_PROJECT: &str = "last_project_path"; const KEY_SELECTED_MODEL: &str = "selected_model"; const KEY_KNOWN_PROJECTS: &str = "known_projects"; +pub fn get_home_directory() -> Result { + let home = homedir::my_home() + .map_err(|e| format!("Failed to resolve home directory: {e}"))? + .ok_or_else(|| "Home directory not found".to_string())?; + Ok(home.to_string_lossy().to_string()) +} + /// Resolves a relative path against the active project root (pure function for testing). /// Returns error if path attempts traversal (..). fn resolve_path_impl(root: PathBuf, relative_path: &str) -> Result { @@ -209,3 +216,8 @@ pub async fn list_directory(path: String, state: &SessionState) -> Result Result, String> { + let full_path = PathBuf::from(path); + list_directory_impl(full_path).await +}