Split into components
This commit is contained in:
@@ -1,53 +1,10 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { api } from "./api/client";
|
import { api } from "./api/client";
|
||||||
import { Chat } from "./components/Chat";
|
import { Chat } from "./components/Chat";
|
||||||
|
import { SelectionScreen } from "./components/selection/SelectionScreen";
|
||||||
|
import { usePathCompletion } from "./components/selection/usePathCompletion";
|
||||||
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 getCurrentPartial(input: string) {
|
|
||||||
const trimmed = input.trim();
|
|
||||||
if (!trimmed) return "";
|
|
||||||
if (trimmed.endsWith("/")) return "";
|
|
||||||
const idx = trimmed.lastIndexOf("/");
|
|
||||||
return idx >= 0 ? trimmed.slice(idx + 1) : trimmed;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderHighlightedMatch(text: string, query: string) {
|
|
||||||
if (!query) return text;
|
|
||||||
let qIndex = 0;
|
|
||||||
const lowerQuery = query.toLowerCase();
|
|
||||||
const counts = new Map<string, number>();
|
|
||||||
return text.split("").map((char) => {
|
|
||||||
const isMatch =
|
|
||||||
qIndex < lowerQuery.length && char.toLowerCase() === lowerQuery[qIndex];
|
|
||||||
if (isMatch) {
|
|
||||||
qIndex += 1;
|
|
||||||
}
|
|
||||||
const count = counts.get(char) ?? 0;
|
|
||||||
counts.set(char, count + 1);
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
key={`${char}-${count}`}
|
|
||||||
style={isMatch ? { fontWeight: 600, color: "#222" } : undefined}
|
|
||||||
>
|
|
||||||
{char}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
||||||
@@ -56,15 +13,6 @@ function App() {
|
|||||||
const [knownProjects, setKnownProjects] = React.useState<string[]>([]);
|
const [knownProjects, setKnownProjects] = React.useState<string[]>([]);
|
||||||
const [homeDir, setHomeDir] = React.useState<string | null>(null);
|
const [homeDir, setHomeDir] = 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
|
||||||
.getKnownProjects()
|
.getKnownProjects()
|
||||||
@@ -96,97 +44,22 @@ function App() {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
React.useEffect(() => {
|
const {
|
||||||
let active = true;
|
matchList,
|
||||||
|
selectedMatch,
|
||||||
async function computeSuggestion() {
|
suggestionTail,
|
||||||
setCompletionError(null);
|
completionError,
|
||||||
setSuggestionTail("");
|
currentPartial,
|
||||||
setMatchList([]);
|
setSelectedMatch,
|
||||||
setSelectedMatch(0);
|
acceptSelectedMatch,
|
||||||
|
acceptMatch,
|
||||||
const trimmed = pathInput.trim();
|
closeSuggestions,
|
||||||
if (!trimmed) {
|
} = usePathCompletion({
|
||||||
return;
|
pathInput,
|
||||||
}
|
setPathInput,
|
||||||
|
homeDir,
|
||||||
const endsWithSlash = trimmed.endsWith("/");
|
listDirectoryAbsolute: api.listDirectoryAbsolute,
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
const debounceId = window.setTimeout(() => {
|
|
||||||
computeSuggestion().catch((error) => {
|
|
||||||
console.error(error);
|
|
||||||
if (!active) return;
|
|
||||||
setCompletionError(
|
|
||||||
error instanceof Error
|
|
||||||
? error.message
|
|
||||||
: "Failed to compute suggestion.",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}, 60);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
active = false;
|
|
||||||
window.clearTimeout(debounceId);
|
|
||||||
};
|
|
||||||
}, [pathInput, homeDir]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (matchList.length === 0) {
|
|
||||||
setSuggestionTail("");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const index = Math.min(selectedMatch, matchList.length - 1);
|
|
||||||
const next = matchList[index];
|
|
||||||
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()) {
|
||||||
@@ -217,6 +90,15 @@ function App() {
|
|||||||
void openProject(pathInput);
|
void openProject(pathInput);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleForgetProject(path: string) {
|
||||||
|
try {
|
||||||
|
await api.forgetKnownProject(path);
|
||||||
|
setKnownProjects((prev) => prev.filter((p) => p !== path));
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function closeProject() {
|
async function closeProject() {
|
||||||
try {
|
try {
|
||||||
await api.closeProject();
|
await api.closeProject();
|
||||||
@@ -226,7 +108,33 @@ function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentPartial = getCurrentPartial(pathInput);
|
function handlePathInputKeyDown(
|
||||||
|
event: React.KeyboardEvent<HTMLInputElement>,
|
||||||
|
) {
|
||||||
|
if (event.key === "ArrowDown") {
|
||||||
|
if (matchList.length > 0) {
|
||||||
|
event.preventDefault();
|
||||||
|
setSelectedMatch((selectedMatch + 1) % matchList.length);
|
||||||
|
}
|
||||||
|
} else if (event.key === "ArrowUp") {
|
||||||
|
if (matchList.length > 0) {
|
||||||
|
event.preventDefault();
|
||||||
|
setSelectedMatch(
|
||||||
|
(selectedMatch - 1 + matchList.length) % matchList.length,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (event.key === "Tab") {
|
||||||
|
if (matchList.length > 0) {
|
||||||
|
event.preventDefault();
|
||||||
|
acceptSelectedMatch();
|
||||||
|
}
|
||||||
|
} else if (event.key === "Escape") {
|
||||||
|
event.preventDefault();
|
||||||
|
closeSuggestions();
|
||||||
|
} else if (event.key === "Enter") {
|
||||||
|
handleOpen();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main
|
<main
|
||||||
@@ -234,256 +142,23 @@ function App() {
|
|||||||
style={{ height: "100vh", padding: 0, maxWidth: "100%" }}
|
style={{ height: "100vh", padding: 0, maxWidth: "100%" }}
|
||||||
>
|
>
|
||||||
{!projectPath ? (
|
{!projectPath ? (
|
||||||
<div
|
<SelectionScreen
|
||||||
className="selection-screen"
|
knownProjects={knownProjects}
|
||||||
style={{ padding: "2rem", maxWidth: "800px", margin: "0 auto" }}
|
onOpenProject={openProject}
|
||||||
>
|
onForgetProject={handleForgetProject}
|
||||||
<h1>AI Code Assistant</h1>
|
pathInput={pathInput}
|
||||||
<p>Paste or complete a project path to start.</p>
|
onPathInputChange={setPathInput}
|
||||||
{knownProjects.length > 0 && (
|
onPathInputKeyDown={handlePathInputKeyDown}
|
||||||
<div style={{ marginTop: "12px" }}>
|
isOpening={isOpening}
|
||||||
<div style={{ fontSize: "0.9em", color: "#666" }}>
|
suggestionTail={suggestionTail}
|
||||||
Recent projects
|
matchList={matchList}
|
||||||
</div>
|
selectedMatch={selectedMatch}
|
||||||
<ul style={{ listStyle: "none", padding: 0, margin: "8px 0 0" }}>
|
onSelectMatch={setSelectedMatch}
|
||||||
{knownProjects.map((project) => {
|
onAcceptMatch={acceptMatch}
|
||||||
const displayName =
|
onCloseSuggestions={closeSuggestions}
|
||||||
project.split("/").filter(Boolean).pop() ?? project;
|
completionError={completionError}
|
||||||
return (
|
currentPartial={currentPartial}
|
||||||
<li key={project} style={{ marginBottom: "6px" }}>
|
/>
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
gap: "6px",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => void openProject(project)}
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
textAlign: "left",
|
|
||||||
padding: "8px 10px",
|
|
||||||
borderRadius: "6px",
|
|
||||||
border: "1px solid #ddd",
|
|
||||||
background: "#f7f7f7",
|
|
||||||
cursor: "pointer",
|
|
||||||
fontFamily: "monospace",
|
|
||||||
fontSize: "0.9em",
|
|
||||||
}}
|
|
||||||
title={project}
|
|
||||||
>
|
|
||||||
{displayName}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label={`Forget ${displayName}`}
|
|
||||||
onClick={() => {
|
|
||||||
void (async () => {
|
|
||||||
try {
|
|
||||||
await api.forgetKnownProject(project);
|
|
||||||
setKnownProjects((prev) =>
|
|
||||||
prev.filter((p) => p !== project),
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
width: "32px",
|
|
||||||
height: "32px",
|
|
||||||
borderRadius: "6px",
|
|
||||||
border: "1px solid #ddd",
|
|
||||||
background: "#fff",
|
|
||||||
cursor: "pointer",
|
|
||||||
fontSize: "1.1em",
|
|
||||||
lineHeight: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "relative",
|
|
||||||
marginTop: "12px",
|
|
||||||
marginBottom: "170px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
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 === "Escape") {
|
|
||||||
event.preventDefault();
|
|
||||||
setMatchList([]);
|
|
||||||
setSelectedMatch(0);
|
|
||||||
setSuggestionTail("");
|
|
||||||
setCompletionError(null);
|
|
||||||
} 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,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "flex-end",
|
|
||||||
alignItems: "center",
|
|
||||||
padding: "4px 6px",
|
|
||||||
borderBottom: "1px solid #eee",
|
|
||||||
background: "#fafafa",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label="Close suggestions"
|
|
||||||
onClick={() => {
|
|
||||||
setMatchList([]);
|
|
||||||
setSelectedMatch(0);
|
|
||||||
setSuggestionTail("");
|
|
||||||
setCompletionError(null);
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
width: "24px",
|
|
||||||
height: "24px",
|
|
||||||
borderRadius: "4px",
|
|
||||||
border: "1px solid #ddd",
|
|
||||||
background: "#fff",
|
|
||||||
cursor: "pointer",
|
|
||||||
lineHeight: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{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",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{renderHighlightedMatch(match.name, currentPartial)}/
|
|
||||||
</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 className="workspace" style={{ height: "100%" }}>
|
<div className="workspace" style={{ height: "100%" }}>
|
||||||
<Chat projectPath={projectPath} onCloseProject={closeProject} />
|
<Chat projectPath={projectPath} onCloseProject={closeProject} />
|
||||||
|
|||||||
170
frontend/src/components/selection/ProjectPathInput.tsx
Normal file
170
frontend/src/components/selection/ProjectPathInput.tsx
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
export interface ProjectPathMatch {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectPathInputProps {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
onKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) => void;
|
||||||
|
suggestionTail: string;
|
||||||
|
matchList: ProjectPathMatch[];
|
||||||
|
selectedMatch: number;
|
||||||
|
onSelectMatch: (index: number) => void;
|
||||||
|
onAcceptMatch: (path: string) => void;
|
||||||
|
onCloseSuggestions: () => void;
|
||||||
|
currentPartial: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderHighlightedMatch(text: string, query: string) {
|
||||||
|
if (!query) return text;
|
||||||
|
let qIndex = 0;
|
||||||
|
const lowerQuery = query.toLowerCase();
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
return text.split("").map((char) => {
|
||||||
|
const isMatch =
|
||||||
|
qIndex < lowerQuery.length && char.toLowerCase() === lowerQuery[qIndex];
|
||||||
|
if (isMatch) {
|
||||||
|
qIndex += 1;
|
||||||
|
}
|
||||||
|
const count = counts.get(char) ?? 0;
|
||||||
|
counts.set(char, count + 1);
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={`${char}-${count}`}
|
||||||
|
style={isMatch ? { fontWeight: 600, color: "#222" } : undefined}
|
||||||
|
>
|
||||||
|
{char}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProjectPathInput({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
onKeyDown,
|
||||||
|
suggestionTail,
|
||||||
|
matchList,
|
||||||
|
selectedMatch,
|
||||||
|
onSelectMatch,
|
||||||
|
onAcceptMatch,
|
||||||
|
onCloseSuggestions,
|
||||||
|
currentPartial,
|
||||||
|
}: ProjectPathInputProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "relative",
|
||||||
|
marginTop: "12px",
|
||||||
|
marginBottom: "170px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
inset: 0,
|
||||||
|
padding: "10px",
|
||||||
|
color: "#aaa",
|
||||||
|
fontFamily: "monospace",
|
||||||
|
whiteSpace: "pre",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
pointerEvents: "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
{suggestionTail}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={value}
|
||||||
|
placeholder="/path/to/project"
|
||||||
|
onChange={(event) => onChange(event.target.value)}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
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,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: "4px 6px",
|
||||||
|
borderBottom: "1px solid #eee",
|
||||||
|
background: "#fafafa",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Close suggestions"
|
||||||
|
onClick={onCloseSuggestions}
|
||||||
|
style={{
|
||||||
|
width: "24px",
|
||||||
|
height: "24px",
|
||||||
|
borderRadius: "4px",
|
||||||
|
border: "1px solid #ddd",
|
||||||
|
background: "#fff",
|
||||||
|
cursor: "pointer",
|
||||||
|
lineHeight: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{matchList.map((match, index) => {
|
||||||
|
const isSelected = index === selectedMatch;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={match.path}
|
||||||
|
type="button"
|
||||||
|
onMouseEnter={() => onSelectMatch(index)}
|
||||||
|
onMouseDown={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
onSelectMatch(index);
|
||||||
|
onAcceptMatch(match.path);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
textAlign: "left",
|
||||||
|
padding: "6px 8px",
|
||||||
|
border: "none",
|
||||||
|
background: isSelected ? "#f0f0f0" : "transparent",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontFamily: "inherit",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{renderHighlightedMatch(match.name, currentPartial)}/
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
66
frontend/src/components/selection/RecentProjectsList.tsx
Normal file
66
frontend/src/components/selection/RecentProjectsList.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
export interface RecentProjectsListProps {
|
||||||
|
projects: string[];
|
||||||
|
onOpenProject: (path: string) => void;
|
||||||
|
onForgetProject: (path: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RecentProjectsList({
|
||||||
|
projects,
|
||||||
|
onOpenProject,
|
||||||
|
onForgetProject,
|
||||||
|
}: RecentProjectsListProps) {
|
||||||
|
return (
|
||||||
|
<div style={{ marginTop: "12px" }}>
|
||||||
|
<div style={{ fontSize: "0.9em", color: "#666" }}>Recent projects</div>
|
||||||
|
<ul style={{ listStyle: "none", padding: 0, margin: "8px 0 0" }}>
|
||||||
|
{projects.map((project) => {
|
||||||
|
const displayName =
|
||||||
|
project.split("/").filter(Boolean).pop() ?? project;
|
||||||
|
return (
|
||||||
|
<li key={project} style={{ marginBottom: "6px" }}>
|
||||||
|
<div
|
||||||
|
style={{ display: "flex", gap: "6px", alignItems: "center" }}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onOpenProject(project)}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
textAlign: "left",
|
||||||
|
padding: "8px 10px",
|
||||||
|
borderRadius: "6px",
|
||||||
|
border: "1px solid #ddd",
|
||||||
|
background: "#f7f7f7",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontFamily: "monospace",
|
||||||
|
fontSize: "0.9em",
|
||||||
|
}}
|
||||||
|
title={project}
|
||||||
|
>
|
||||||
|
{displayName}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`Forget ${displayName}`}
|
||||||
|
onClick={() => onForgetProject(project)}
|
||||||
|
style={{
|
||||||
|
width: "32px",
|
||||||
|
height: "32px",
|
||||||
|
borderRadius: "6px",
|
||||||
|
border: "1px solid #ddd",
|
||||||
|
background: "#fff",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "1.1em",
|
||||||
|
lineHeight: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
99
frontend/src/components/selection/SelectionScreen.tsx
Normal file
99
frontend/src/components/selection/SelectionScreen.tsx
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import type { KeyboardEvent } from "react";
|
||||||
|
import { ProjectPathInput } from "./ProjectPathInput.tsx";
|
||||||
|
import { RecentProjectsList } from "./RecentProjectsList.tsx";
|
||||||
|
|
||||||
|
export interface RecentProjectMatch {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SelectionScreenProps {
|
||||||
|
knownProjects: string[];
|
||||||
|
onOpenProject: (path: string) => void;
|
||||||
|
onForgetProject: (path: string) => void;
|
||||||
|
pathInput: string;
|
||||||
|
onPathInputChange: (value: string) => void;
|
||||||
|
onPathInputKeyDown: (event: KeyboardEvent<HTMLInputElement>) => void;
|
||||||
|
isOpening: boolean;
|
||||||
|
suggestionTail: string;
|
||||||
|
matchList: RecentProjectMatch[];
|
||||||
|
selectedMatch: number;
|
||||||
|
onSelectMatch: (index: number) => void;
|
||||||
|
onAcceptMatch: (path: string) => void;
|
||||||
|
onCloseSuggestions: () => void;
|
||||||
|
completionError: string | null;
|
||||||
|
currentPartial: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SelectionScreen({
|
||||||
|
knownProjects,
|
||||||
|
onOpenProject,
|
||||||
|
onForgetProject,
|
||||||
|
pathInput,
|
||||||
|
onPathInputChange,
|
||||||
|
onPathInputKeyDown,
|
||||||
|
isOpening,
|
||||||
|
suggestionTail,
|
||||||
|
matchList,
|
||||||
|
selectedMatch,
|
||||||
|
onSelectMatch,
|
||||||
|
onAcceptMatch,
|
||||||
|
onCloseSuggestions,
|
||||||
|
completionError,
|
||||||
|
currentPartial,
|
||||||
|
}: SelectionScreenProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="selection-screen"
|
||||||
|
style={{ padding: "2rem", maxWidth: "800px", margin: "0 auto" }}
|
||||||
|
>
|
||||||
|
<h1>AI Code Assistant</h1>
|
||||||
|
<p>Paste or complete a project path to start.</p>
|
||||||
|
|
||||||
|
{knownProjects.length > 0 && (
|
||||||
|
<RecentProjectsList
|
||||||
|
projects={knownProjects}
|
||||||
|
onOpenProject={onOpenProject}
|
||||||
|
onForgetProject={onForgetProject}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ProjectPathInput
|
||||||
|
value={pathInput}
|
||||||
|
onChange={onPathInputChange}
|
||||||
|
onKeyDown={onPathInputKeyDown}
|
||||||
|
suggestionTail={suggestionTail}
|
||||||
|
matchList={matchList}
|
||||||
|
selectedMatch={selectedMatch}
|
||||||
|
onSelectMatch={onSelectMatch}
|
||||||
|
onAcceptMatch={onAcceptMatch}
|
||||||
|
onCloseSuggestions={onCloseSuggestions}
|
||||||
|
currentPartial={currentPartial}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
gap: "8px",
|
||||||
|
marginTop: "8px",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onOpenProject(pathInput)}
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
192
frontend/src/components/selection/usePathCompletion.ts
Normal file
192
frontend/src/components/selection/usePathCompletion.ts
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
export interface FileEntry {
|
||||||
|
name: string;
|
||||||
|
kind: "file" | "dir";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectPathMatch {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UsePathCompletionArgs {
|
||||||
|
pathInput: string;
|
||||||
|
setPathInput: (value: string) => void;
|
||||||
|
homeDir: string | null;
|
||||||
|
listDirectoryAbsolute: (path: string) => Promise<FileEntry[]>;
|
||||||
|
debounceMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UsePathCompletionResult {
|
||||||
|
matchList: ProjectPathMatch[];
|
||||||
|
selectedMatch: number;
|
||||||
|
suggestionTail: string;
|
||||||
|
completionError: string | null;
|
||||||
|
currentPartial: string;
|
||||||
|
setSelectedMatch: (index: number) => void;
|
||||||
|
acceptSelectedMatch: () => void;
|
||||||
|
acceptMatch: (path: string) => void;
|
||||||
|
closeSuggestions: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 getCurrentPartial(input: string) {
|
||||||
|
const trimmed = input.trim();
|
||||||
|
if (!trimmed) return "";
|
||||||
|
if (trimmed.endsWith("/")) return "";
|
||||||
|
const idx = trimmed.lastIndexOf("/");
|
||||||
|
return idx >= 0 ? trimmed.slice(idx + 1) : trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePathCompletion({
|
||||||
|
pathInput,
|
||||||
|
setPathInput,
|
||||||
|
homeDir,
|
||||||
|
listDirectoryAbsolute,
|
||||||
|
debounceMs = 60,
|
||||||
|
}: UsePathCompletionArgs): UsePathCompletionResult {
|
||||||
|
const [matchList, setMatchList] = React.useState<ProjectPathMatch[]>([]);
|
||||||
|
const [selectedMatch, setSelectedMatch] = React.useState(0);
|
||||||
|
const [suggestionTail, setSuggestionTail] = React.useState("");
|
||||||
|
const [completionError, setCompletionError] = React.useState<string | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
|
||||||
|
async function computeSuggestion() {
|
||||||
|
setCompletionError(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 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
const debounceId = window.setTimeout(() => {
|
||||||
|
computeSuggestion().catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
if (!active) return;
|
||||||
|
setCompletionError(
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Failed to compute suggestion.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}, debounceMs);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
window.clearTimeout(debounceId);
|
||||||
|
};
|
||||||
|
}, [pathInput, homeDir, listDirectoryAbsolute, debounceMs]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (matchList.length === 0) {
|
||||||
|
setSuggestionTail("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const index = Math.min(selectedMatch, matchList.length - 1);
|
||||||
|
const next = matchList[index];
|
||||||
|
const trimmed = pathInput.trim();
|
||||||
|
if (next.path.startsWith(trimmed)) {
|
||||||
|
setSuggestionTail(next.path.slice(trimmed.length));
|
||||||
|
} else {
|
||||||
|
setSuggestionTail("");
|
||||||
|
}
|
||||||
|
}, [matchList, selectedMatch, pathInput]);
|
||||||
|
|
||||||
|
const acceptMatch = React.useCallback(
|
||||||
|
(path: string) => {
|
||||||
|
setPathInput(path);
|
||||||
|
},
|
||||||
|
[setPathInput],
|
||||||
|
);
|
||||||
|
|
||||||
|
const acceptSelectedMatch = React.useCallback(() => {
|
||||||
|
const next = matchList[selectedMatch]?.path;
|
||||||
|
if (next) {
|
||||||
|
setPathInput(next);
|
||||||
|
}
|
||||||
|
}, [matchList, selectedMatch, setPathInput]);
|
||||||
|
|
||||||
|
const closeSuggestions = React.useCallback(() => {
|
||||||
|
setMatchList([]);
|
||||||
|
setSelectedMatch(0);
|
||||||
|
setSuggestionTail("");
|
||||||
|
setCompletionError(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
matchList,
|
||||||
|
selectedMatch,
|
||||||
|
suggestionTail,
|
||||||
|
completionError,
|
||||||
|
currentPartial: getCurrentPartial(pathInput),
|
||||||
|
setSelectedMatch,
|
||||||
|
acceptSelectedMatch,
|
||||||
|
acceptMatch,
|
||||||
|
closeSuggestions,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user