Text-completion picker to select a project
This commit is contained in:
134
Cargo.lock
generated
134
Cargo.lock
generated
@@ -155,7 +155,7 @@ dependencies = [
|
|||||||
"num-traits",
|
"num-traits",
|
||||||
"serde",
|
"serde",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
"windows-link",
|
"windows-link 0.2.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -643,6 +643,18 @@ version = "0.5.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
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]]
|
[[package]]
|
||||||
name = "http"
|
name = "http"
|
||||||
version = "1.4.0"
|
version = "1.4.0"
|
||||||
@@ -764,7 +776,7 @@ dependencies = [
|
|||||||
"js-sys",
|
"js-sys",
|
||||||
"log",
|
"log",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
"windows-core",
|
"windows-core 0.62.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1156,7 +1168,7 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
"redox_syscall",
|
"redox_syscall",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
"windows-link",
|
"windows-link 0.2.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1873,6 +1885,7 @@ dependencies = [
|
|||||||
"chrono",
|
"chrono",
|
||||||
"eventsource-stream",
|
"eventsource-stream",
|
||||||
"futures",
|
"futures",
|
||||||
|
"homedir",
|
||||||
"ignore",
|
"ignore",
|
||||||
"mime_guess",
|
"mime_guess",
|
||||||
"poem",
|
"poem",
|
||||||
@@ -2501,6 +2514,12 @@ dependencies = [
|
|||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "widestring"
|
||||||
|
version = "1.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wildmatch"
|
name = "wildmatch"
|
||||||
version = "2.6.1"
|
version = "2.6.1"
|
||||||
@@ -2516,6 +2535,41 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "windows-core"
|
name = "windows-core"
|
||||||
version = "0.62.2"
|
version = "0.62.2"
|
||||||
@@ -2524,9 +2578,20 @@ checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-implement",
|
"windows-implement",
|
||||||
"windows-interface",
|
"windows-interface",
|
||||||
"windows-link",
|
"windows-link 0.2.1",
|
||||||
"windows-result",
|
"windows-result 0.4.1",
|
||||||
"windows-strings",
|
"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]]
|
[[package]]
|
||||||
@@ -2551,21 +2616,46 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-link"
|
||||||
|
version = "0.1.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-link"
|
name = "windows-link"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
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]]
|
[[package]]
|
||||||
name = "windows-registry"
|
name = "windows-registry"
|
||||||
version = "0.6.1"
|
version = "0.6.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
|
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-link",
|
"windows-link 0.2.1",
|
||||||
"windows-result",
|
"windows-result 0.4.1",
|
||||||
"windows-strings",
|
"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]]
|
[[package]]
|
||||||
@@ -2574,7 +2664,16 @@ version = "0.4.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
|
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
|
||||||
dependencies = [
|
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]]
|
[[package]]
|
||||||
@@ -2583,7 +2682,7 @@ version = "0.5.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
|
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-link",
|
"windows-link 0.2.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2619,7 +2718,7 @@ version = "0.61.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-link",
|
"windows-link 0.2.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2659,7 +2758,7 @@ version = "0.53.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
|
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-link",
|
"windows-link 0.2.1",
|
||||||
"windows_aarch64_gnullvm 0.53.1",
|
"windows_aarch64_gnullvm 0.53.1",
|
||||||
"windows_aarch64_msvc 0.53.1",
|
"windows_aarch64_msvc 0.53.1",
|
||||||
"windows_i686_gnu 0.53.1",
|
"windows_i686_gnu 0.53.1",
|
||||||
@@ -2670,6 +2769,15 @@ dependencies = [
|
|||||||
"windows_x86_64_msvc 0.53.1",
|
"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]]
|
[[package]]
|
||||||
name = "windows_aarch64_gnullvm"
|
name = "windows_aarch64_gnullvm"
|
||||||
version = "0.42.2"
|
version = "0.42.2"
|
||||||
|
|||||||
@@ -18,3 +18,4 @@ walkdir = "2.5.0"
|
|||||||
eventsource-stream = "0.2.3"
|
eventsource-stream = "0.2.3"
|
||||||
rust-embed = "8"
|
rust-embed = "8"
|
||||||
mime_guess = "2"
|
mime_guess = "2"
|
||||||
|
homedir = "0.3.6"
|
||||||
|
|||||||
@@ -3,12 +3,36 @@ import { api } from "./api/client";
|
|||||||
import { Chat } from "./components/Chat";
|
import { Chat } from "./components/Chat";
|
||||||
import "./App.css";
|
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() {
|
function App() {
|
||||||
const [projectPath, setProjectPath] = React.useState<string | null>(null);
|
const [projectPath, setProjectPath] = React.useState<string | null>(null);
|
||||||
const [errorMsg, setErrorMsg] = React.useState<string | null>(null);
|
const [errorMsg, setErrorMsg] = React.useState<string | null>(null);
|
||||||
const [pathInput, setPathInput] = React.useState("");
|
const [pathInput, setPathInput] = React.useState("");
|
||||||
const [isOpening, setIsOpening] = React.useState(false);
|
const [isOpening, setIsOpening] = React.useState(false);
|
||||||
const [knownProjects, setKnownProjects] = React.useState<string[]>([]);
|
const [knownProjects, setKnownProjects] = React.useState<string[]>([]);
|
||||||
|
const [homeDir, setHomeDir] = React.useState<string | null>(null);
|
||||||
|
|
||||||
|
const [suggestion, setSuggestion] = React.useState<string | null>(null);
|
||||||
|
const [suggestionTail, setSuggestionTail] = React.useState("");
|
||||||
|
const [completionError, setCompletionError] = React.useState<string | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [matchList, setMatchList] = React.useState<
|
||||||
|
{ name: string; path: string }[]
|
||||||
|
>([]);
|
||||||
|
const [selectedMatch, setSelectedMatch] = React.useState(0);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
api
|
api
|
||||||
@@ -17,6 +41,122 @@ function App() {
|
|||||||
.catch((error) => console.error(error));
|
.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) {
|
async function openProject(path: string) {
|
||||||
if (!path.trim()) {
|
if (!path.trim()) {
|
||||||
setErrorMsg("Please enter a project path.");
|
setErrorMsg("Please enter a project path.");
|
||||||
@@ -66,7 +206,7 @@ function App() {
|
|||||||
style={{ padding: "2rem", maxWidth: "800px", margin: "0 auto" }}
|
style={{ padding: "2rem", maxWidth: "800px", margin: "0 auto" }}
|
||||||
>
|
>
|
||||||
<h1>AI Code Assistant</h1>
|
<h1>AI Code Assistant</h1>
|
||||||
<p>Paste a project path to start the Story-Driven Spec Workflow.</p>
|
<p>Paste or complete a project path to start.</p>
|
||||||
{knownProjects.length > 0 && (
|
{knownProjects.length > 0 && (
|
||||||
<div style={{ marginTop: "12px" }}>
|
<div style={{ marginTop: "12px" }}>
|
||||||
<div style={{ fontSize: "0.9em", color: "#666" }}>
|
<div style={{ fontSize: "0.9em", color: "#666" }}>
|
||||||
@@ -97,21 +237,140 @@ function App() {
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<input
|
|
||||||
type="text"
|
<div
|
||||||
value={pathInput}
|
style={{
|
||||||
placeholder="/path/to/project"
|
position: "relative",
|
||||||
onChange={(event) => setPathInput(event.target.value)}
|
marginTop: "12px",
|
||||||
onKeyDown={(event) => {
|
marginBottom: "170px",
|
||||||
if (event.key === "Enter") {
|
|
||||||
handleOpen();
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
style={{ width: "100%", padding: "10px", marginTop: "12px" }}
|
>
|
||||||
/>
|
<div
|
||||||
<button type="button" onClick={handleOpen} disabled={isOpening}>
|
style={{
|
||||||
{isOpening ? "Opening..." : "Open Project"}
|
position: "absolute",
|
||||||
</button>
|
inset: 0,
|
||||||
|
padding: "10px",
|
||||||
|
color: "#aaa",
|
||||||
|
fontFamily: "monospace",
|
||||||
|
whiteSpace: "pre",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
pointerEvents: "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{pathInput}
|
||||||
|
{suggestionTail}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={pathInput}
|
||||||
|
placeholder="/path/to/project"
|
||||||
|
onChange={(event) => 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 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: "100%",
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
marginTop: "6px",
|
||||||
|
border: "1px solid #ddd",
|
||||||
|
borderRadius: "6px",
|
||||||
|
overflow: "hidden",
|
||||||
|
background: "#fff",
|
||||||
|
fontFamily: "monospace",
|
||||||
|
height: "160px",
|
||||||
|
overflowY: "auto",
|
||||||
|
boxSizing: "border-box",
|
||||||
|
zIndex: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{matchList.map((match, index) => {
|
||||||
|
const isSelected = index === selectedMatch;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={match.path}
|
||||||
|
type="button"
|
||||||
|
onMouseEnter={() => setSelectedMatch(index)}
|
||||||
|
onMouseDown={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setSelectedMatch(index);
|
||||||
|
setPathInput(match.path);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
textAlign: "left",
|
||||||
|
padding: "6px 8px",
|
||||||
|
border: "none",
|
||||||
|
background: isSelected ? "#f0f0f0" : "transparent",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontFamily: "inherit",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{match.name}/
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
gap: "8px",
|
||||||
|
marginTop: "8px",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button type="button" onClick={handleOpen} disabled={isOpening}>
|
||||||
|
{isOpening ? "Opening..." : "Open Project"}
|
||||||
|
</button>
|
||||||
|
<div style={{ fontSize: "0.85em", color: "#666" }}>
|
||||||
|
Press Tab to complete the next path segment
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{completionError && (
|
||||||
|
<div style={{ color: "red", marginTop: "8px" }}>
|
||||||
|
{completionError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="workspace" style={{ height: "100%" }}>
|
<div className="workspace" style={{ height: "100%" }}>
|
||||||
|
|||||||
@@ -153,6 +153,16 @@ export const api = {
|
|||||||
baseUrl,
|
baseUrl,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
listDirectoryAbsolute(path: string, baseUrl?: string) {
|
||||||
|
return requestJson<FileEntry[]>(
|
||||||
|
"/io/fs/list/absolute",
|
||||||
|
{ method: "POST", body: JSON.stringify({ path }) },
|
||||||
|
baseUrl,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
getHomeDirectory(baseUrl?: string) {
|
||||||
|
return requestJson<string>("/io/fs/home", {}, baseUrl);
|
||||||
|
},
|
||||||
searchFiles(query: string, baseUrl?: string) {
|
searchFiles(query: string, baseUrl?: string) {
|
||||||
return requestJson<SearchResult[]>(
|
return requestJson<SearchResult[]>(
|
||||||
"/fs/search",
|
"/fs/search",
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ walkdir = { workspace = true }
|
|||||||
eventsource-stream = { workspace = true }
|
eventsource-stream = { workspace = true }
|
||||||
rust-embed = { workspace = true }
|
rust-embed = { workspace = true }
|
||||||
mime_guess = { workspace = true }
|
mime_guess = { workspace = true }
|
||||||
|
homedir = { workspace = true }
|
||||||
|
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
|
|||||||
@@ -67,6 +67,25 @@ impl IoApi {
|
|||||||
Ok(Json(entries))
|
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<FilePathPayload>,
|
||||||
|
) -> OpenApiResult<Json<Vec<io_fs::FileEntry>>> {
|
||||||
|
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<Json<String>> {
|
||||||
|
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.
|
/// Search the currently open project for files containing the provided query string.
|
||||||
#[oai(path = "/io/search", method = "post")]
|
#[oai(path = "/io/search", method = "post")]
|
||||||
async fn search_files(
|
async fn search_files(
|
||||||
|
|||||||
@@ -9,6 +9,13 @@ const KEY_LAST_PROJECT: &str = "last_project_path";
|
|||||||
const KEY_SELECTED_MODEL: &str = "selected_model";
|
const KEY_SELECTED_MODEL: &str = "selected_model";
|
||||||
const KEY_KNOWN_PROJECTS: &str = "known_projects";
|
const KEY_KNOWN_PROJECTS: &str = "known_projects";
|
||||||
|
|
||||||
|
pub fn get_home_directory() -> Result<String, String> {
|
||||||
|
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).
|
/// Resolves a relative path against the active project root (pure function for testing).
|
||||||
/// Returns error if path attempts traversal (..).
|
/// Returns error if path attempts traversal (..).
|
||||||
fn resolve_path_impl(root: PathBuf, relative_path: &str) -> Result<PathBuf, String> {
|
fn resolve_path_impl(root: PathBuf, relative_path: &str) -> Result<PathBuf, String> {
|
||||||
@@ -209,3 +216,8 @@ pub async fn list_directory(path: String, state: &SessionState) -> Result<Vec<Fi
|
|||||||
let full_path = resolve_path(state, &path)?;
|
let full_path = resolve_path(state, &path)?;
|
||||||
list_directory_impl(full_path).await
|
list_directory_impl(full_path).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn list_directory_absolute(path: String) -> Result<Vec<FileEntry>, String> {
|
||||||
|
let full_path = PathBuf::from(path);
|
||||||
|
list_directory_impl(full_path).await
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user