Files
storkit/frontend/src/App.tsx

503 lines
15 KiB
TypeScript
Raw Normal View History

import * as React from "react";
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;
}
2026-02-16 19:48:39 +00:00
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();
2026-02-16 19:53:31 +00:00
const counts = new Map<string, number>();
return text.split("").map((char) => {
2026-02-16 19:48:39 +00:00
const isMatch =
qIndex < lowerQuery.length && char.toLowerCase() === lowerQuery[qIndex];
if (isMatch) {
qIndex += 1;
}
2026-02-16 19:53:31 +00:00
const count = counts.get(char) ?? 0;
counts.set(char, count + 1);
2026-02-16 19:48:39 +00:00
return (
<span
2026-02-16 19:53:31 +00:00
key={`${char}-${count}`}
2026-02-16 19:48:39 +00:00
style={isMatch ? { fontWeight: 600, color: "#222" } : undefined}
>
{char}
</span>
);
});
}
function App() {
2026-02-16 18:57:39 +00:00
const [projectPath, setProjectPath] = React.useState<string | null>(null);
const [errorMsg, setErrorMsg] = React.useState<string | null>(null);
const [pathInput, setPathInput] = React.useState("");
const [isOpening, setIsOpening] = React.useState(false);
const [knownProjects, setKnownProjects] = React.useState<string[]>([]);
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);
2026-02-16 18:57:39 +00:00
React.useEffect(() => {
api
.getKnownProjects()
.then((projects) => setKnownProjects(projects))
.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);
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);
}
2026-02-16 19:48:39 +00:00
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;
2026-02-16 19:48:39 +00:00
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]);
2026-02-16 18:57:39 +00:00
async function openProject(path: string) {
if (!path.trim()) {
setErrorMsg("Please enter a project path.");
return;
}
2026-02-16 18:57:39 +00:00
try {
setErrorMsg(null);
setIsOpening(true);
const confirmedPath = await api.openProject(path.trim());
setProjectPath(confirmedPath);
} catch (e) {
console.error(e);
const message =
e instanceof Error
? e.message
: typeof e === "string"
? e
: "An error occurred opening the project.";
setErrorMsg(message);
} finally {
setIsOpening(false);
}
}
2026-02-16 18:57:39 +00:00
function handleOpen() {
void openProject(pathInput);
}
2026-02-16 18:57:39 +00:00
async function closeProject() {
try {
await api.closeProject();
setProjectPath(null);
} catch (e) {
console.error(e);
}
}
2026-02-16 19:48:39 +00:00
const currentPartial = getCurrentPartial(pathInput);
2026-02-16 18:57:39 +00:00
return (
<main
className="container"
style={{ height: "100vh", padding: 0, maxWidth: "100%" }}
>
{!projectPath ? (
<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>
2026-02-16 18:57:39 +00:00
{knownProjects.length > 0 && (
<div style={{ marginTop: "12px" }}>
<div style={{ fontSize: "0.9em", color: "#666" }}>
Recent projects
</div>
<ul style={{ listStyle: "none", padding: 0, margin: "8px 0 0" }}>
2026-02-16 19:53:31 +00:00
{knownProjects.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={() => 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>
);
})}
2026-02-16 18:57:39 +00:00
</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);
}
}
2026-02-16 19:48:39 +00:00
} 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,
}}
>
2026-02-16 19:53:43 +00:00
<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",
}}
>
2026-02-16 19:48:39 +00:00
{renderHighlightedMatch(match.name, currentPartial)}/
</button>
);
})}
</div>
)}
</div>
<div
style={{
display: "flex",
gap: "8px",
marginTop: "8px",
alignItems: "center",
2026-02-16 18:57:39 +00:00
}}
>
<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>
)}
2026-02-16 18:57:39 +00:00
</div>
) : (
<div className="workspace" style={{ height: "100%" }}>
<Chat projectPath={projectPath} onCloseProject={closeProject} />
</div>
)}
{errorMsg && (
<div className="error-message" style={{ marginTop: "20px" }}>
<p style={{ color: "red" }}>Error: {errorMsg}</p>
</div>
)}
</main>
);
}
export default App;