diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 66c6353..99b9fb6 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,53 +1,10 @@ import * as React from "react"; import { api } from "./api/client"; import { Chat } from "./components/Chat"; +import { SelectionScreen } from "./components/selection/SelectionScreen"; +import { usePathCompletion } from "./components/selection/usePathCompletion"; 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(); - 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 ( - - {char} - - ); - }); -} - function App() { const [projectPath, setProjectPath] = React.useState(null); const [errorMsg, setErrorMsg] = React.useState(null); @@ -56,15 +13,6 @@ function App() { const [knownProjects, setKnownProjects] = React.useState([]); const [homeDir, setHomeDir] = 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 .getKnownProjects() @@ -96,97 +44,22 @@ function App() { }; }, []); - 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 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]); + const { + matchList, + selectedMatch, + suggestionTail, + completionError, + currentPartial, + setSelectedMatch, + acceptSelectedMatch, + acceptMatch, + closeSuggestions, + } = usePathCompletion({ + pathInput, + setPathInput, + homeDir, + listDirectoryAbsolute: api.listDirectoryAbsolute, + }); async function openProject(path: string) { if (!path.trim()) { @@ -217,6 +90,15 @@ function App() { 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() { try { await api.closeProject(); @@ -226,7 +108,33 @@ function App() { } } - const currentPartial = getCurrentPartial(pathInput); + function handlePathInputKeyDown( + event: React.KeyboardEvent, + ) { + 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 (
{!projectPath ? ( -
-

AI Code Assistant

-

Paste or complete a project path to start.

- {knownProjects.length > 0 && ( -
-
- Recent projects -
-
    - {knownProjects.map((project) => { - const displayName = - project.split("/").filter(Boolean).pop() ?? project; - return ( -
  • -
    - - -
    -
  • - ); - })} -
-
- )} - -
-
- {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 === "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 && ( -
-
- -
- {matchList.map((match, index) => { - const isSelected = index === selectedMatch; - return ( - - ); - })} -
- )} -
- -
- -
- Press Tab to complete the next path segment -
-
- - {completionError && ( -
- {completionError} -
- )} -
+ ) : (
diff --git a/frontend/src/components/selection/ProjectPathInput.tsx b/frontend/src/components/selection/ProjectPathInput.tsx new file mode 100644 index 0000000..3a79163 --- /dev/null +++ b/frontend/src/components/selection/ProjectPathInput.tsx @@ -0,0 +1,170 @@ +export interface ProjectPathMatch { + name: string; + path: string; +} + +export interface ProjectPathInputProps { + value: string; + onChange: (value: string) => void; + onKeyDown: (event: React.KeyboardEvent) => 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(); + 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 ( + + {char} + + ); + }); +} + +export function ProjectPathInput({ + value, + onChange, + onKeyDown, + suggestionTail, + matchList, + selectedMatch, + onSelectMatch, + onAcceptMatch, + onCloseSuggestions, + currentPartial, +}: ProjectPathInputProps) { + return ( +
+
+ {value} + {suggestionTail} +
+ onChange(event.target.value)} + onKeyDown={onKeyDown} + 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 ( + + ); + })} +
+ )} +
+ ); +} diff --git a/frontend/src/components/selection/RecentProjectsList.tsx b/frontend/src/components/selection/RecentProjectsList.tsx new file mode 100644 index 0000000..f01120a --- /dev/null +++ b/frontend/src/components/selection/RecentProjectsList.tsx @@ -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 ( +
+
Recent projects
+
    + {projects.map((project) => { + const displayName = + project.split("/").filter(Boolean).pop() ?? project; + return ( +
  • +
    + + +
    +
  • + ); + })} +
+
+ ); +} diff --git a/frontend/src/components/selection/SelectionScreen.tsx b/frontend/src/components/selection/SelectionScreen.tsx new file mode 100644 index 0000000..3f407a7 --- /dev/null +++ b/frontend/src/components/selection/SelectionScreen.tsx @@ -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) => 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 ( +
+

AI Code Assistant

+

Paste or complete a project path to start.

+ + {knownProjects.length > 0 && ( + + )} + + + +
+ +
+ Press Tab to complete the next path segment +
+
+ + {completionError && ( +
{completionError}
+ )} +
+ ); +} diff --git a/frontend/src/components/selection/usePathCompletion.ts b/frontend/src/components/selection/usePathCompletion.ts new file mode 100644 index 0000000..f1ac1eb --- /dev/null +++ b/frontend/src/components/selection/usePathCompletion.ts @@ -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; + 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([]); + const [selectedMatch, setSelectedMatch] = React.useState(0); + const [suggestionTail, setSuggestionTail] = React.useState(""); + const [completionError, setCompletionError] = React.useState( + 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, + }; +}