Skip to content

Commit 1b5acd8

Browse files
committed
feat: add file autocomplete for @ mentions
1 parent 1706cfc commit 1b5acd8

7 files changed

Lines changed: 201 additions & 10 deletions

File tree

src-tauri/src/lib.rs

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,53 @@ fn normalize_git_path(path: &str) -> String {
4949
path.replace('\\', "/")
5050
}
5151

52+
fn should_skip_dir(name: &str) -> bool {
53+
matches!(
54+
name,
55+
".git" | "node_modules" | "dist" | "target" | "release-artifacts"
56+
)
57+
}
58+
59+
fn list_workspace_files_inner(root: &PathBuf, max_files: usize) -> Vec<String> {
60+
let mut results = Vec::new();
61+
let mut stack = vec![root.clone()];
62+
63+
while let Some(dir) = stack.pop() {
64+
let entries = match std::fs::read_dir(&dir) {
65+
Ok(entries) => entries,
66+
Err(_) => continue,
67+
};
68+
for entry in entries.flatten() {
69+
let path = entry.path();
70+
let file_name = entry
71+
.file_name()
72+
.to_string_lossy()
73+
.to_string();
74+
if path.is_dir() {
75+
if should_skip_dir(&file_name) {
76+
continue;
77+
}
78+
stack.push(path);
79+
continue;
80+
}
81+
if path.is_file() {
82+
if let Ok(rel_path) = path.strip_prefix(root) {
83+
let normalized = normalize_git_path(&rel_path.to_string_lossy());
84+
if !normalized.is_empty() {
85+
results.push(normalized);
86+
}
87+
}
88+
}
89+
if results.len() >= max_files {
90+
return results;
91+
}
92+
}
93+
}
94+
95+
results.sort();
96+
results
97+
}
98+
5299
fn diff_stats_for_path(
53100
repo: &Repository,
54101
head_tree: Option<&Tree>,
@@ -1014,6 +1061,19 @@ async fn get_git_remote(
10141061
Ok(remote.url().map(|url| url.to_string()))
10151062
}
10161063

1064+
#[tauri::command]
1065+
async fn list_workspace_files(
1066+
workspace_id: String,
1067+
state: State<'_, AppState>,
1068+
) -> Result<Vec<String>, String> {
1069+
let workspaces = state.workspaces.lock().await;
1070+
let entry = workspaces
1071+
.get(&workspace_id)
1072+
.ok_or("workspace not found")?;
1073+
let root = PathBuf::from(&entry.path);
1074+
Ok(list_workspace_files_inner(&root, 20000))
1075+
}
1076+
10171077
#[cfg_attr(mobile, tauri::mobile_entry_point)]
10181078
pub fn run() {
10191079
tauri::Builder::default()
@@ -1141,6 +1201,7 @@ pub fn run() {
11411201
get_git_diffs,
11421202
get_git_log,
11431203
get_git_remote,
1204+
list_workspace_files,
11441205
model_list,
11451206
account_rate_limits,
11461207
skills_list

src/App.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import { useGitLog } from "./hooks/useGitLog";
4040
import { useGitRemote } from "./hooks/useGitRemote";
4141
import { useModels } from "./hooks/useModels";
4242
import { useSkills } from "./hooks/useSkills";
43+
import { useWorkspaceFiles } from "./hooks/useWorkspaceFiles";
4344
import { useDebugLog } from "./hooks/useDebugLog";
4445
import { useWorkspaceRefreshOnFocus } from "./hooks/useWorkspaceRefreshOnFocus";
4546
import { useWorkspaceRestore } from "./hooks/useWorkspaceRestore";
@@ -139,6 +140,7 @@ function MainApp() {
139140
setSelectedEffort,
140141
} = useModels({ activeWorkspace, onDebug: addDebugEntry });
141142
const { skills } = useSkills({ activeWorkspace, onDebug: addDebugEntry });
143+
const { files } = useWorkspaceFiles({ activeWorkspace, onDebug: addDebugEntry });
142144

143145
const resolvedModel = selectedModel?.model ?? null;
144146
const fileStatus =
@@ -494,6 +496,7 @@ function MainApp() {
494496
accessMode={accessMode}
495497
onSelectAccessMode={setAccessMode}
496498
skills={skills}
499+
files={files}
497500
/>
498501
) : null;
499502

src/components/Composer.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ type ComposerProps = {
1919
accessMode: "read-only" | "current" | "full-access";
2020
onSelectAccessMode: (mode: "read-only" | "current" | "full-access") => void;
2121
skills: { name: string; description?: string }[];
22+
files: string[];
2223
contextUsage?: ThreadTokenUsage | null;
2324
queuedMessages?: QueuedMessage[];
2425
onEditQueued?: (item: QueuedMessage) => void;
@@ -42,6 +43,7 @@ export function Composer({
4243
accessMode,
4344
onSelectAccessMode,
4445
skills,
46+
files,
4547
contextUsage = null,
4648
queuedMessages = [],
4749
onEditQueued,
@@ -80,6 +82,7 @@ export function Composer({
8082
selectionStart,
8183
disabled,
8284
skills,
85+
files,
8386
textareaRef,
8487
setText,
8588
setSelectionStart,

src/components/ComposerInput.tsx

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,13 @@ export function ComposerInput({
3939
}: ComposerInputProps) {
4040
const suggestionListRef = useRef<HTMLDivElement | null>(null);
4141
const suggestionRefs = useRef<Array<HTMLButtonElement | null>>([]);
42+
const isFileSuggestion = (item: AutocompleteItem) =>
43+
item.label.includes("/") || item.label.includes("\\");
44+
const fileTitle = (path: string) => {
45+
const normalized = path.replace(/\\/g, "/");
46+
const parts = normalized.split("/").filter(Boolean);
47+
return parts.length ? parts[parts.length - 1] : path;
48+
};
4249

4350
useEffect(() => {
4451
if (!suggestionsOpen) {
@@ -100,11 +107,24 @@ export function ComposerInput({
100107
onClick={() => onSelectSuggestion(item)}
101108
onMouseEnter={() => onHighlightIndex(index)}
102109
>
103-
<span className="composer-suggestion-title">{item.label}</span>
104-
{item.description && (
105-
<span className="composer-suggestion-description">
106-
{item.description}
107-
</span>
110+
{isFileSuggestion(item) ? (
111+
<>
112+
<span className="composer-suggestion-title">
113+
{fileTitle(item.label)}
114+
</span>
115+
<span className="composer-suggestion-description">
116+
{item.label}
117+
</span>
118+
</>
119+
) : (
120+
<>
121+
<span className="composer-suggestion-title">{item.label}</span>
122+
{item.description && (
123+
<span className="composer-suggestion-description">
124+
{item.description}
125+
</span>
126+
)}
127+
</>
108128
)}
109129
</button>
110130
))}

src/hooks/useComposerAutocompleteState.ts

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ type UseComposerAutocompleteStateArgs = {
99
selectionStart: number | null;
1010
disabled: boolean;
1111
skills: Skill[];
12+
files: string[];
1213
textareaRef: React.RefObject<HTMLTextAreaElement | null>;
1314
setText: (next: string) => void;
1415
setSelectionStart: (next: number | null) => void;
@@ -19,6 +20,7 @@ export function useComposerAutocompleteState({
1920
selectionStart,
2021
disabled,
2122
skills,
23+
files,
2224
textareaRef,
2325
setText,
2426
setSelectionStart,
@@ -34,9 +36,22 @@ export function useComposerAutocompleteState({
3436
[skills],
3537
);
3638

39+
const fileItems = useMemo<AutocompleteItem[]>(
40+
() =>
41+
files.map((path) => ({
42+
id: path,
43+
label: path,
44+
insertText: path,
45+
})),
46+
[files],
47+
);
48+
3749
const triggers = useMemo(
38-
() => [{ trigger: "$", items: skillItems }],
39-
[skillItems],
50+
() => [
51+
{ trigger: "$", items: skillItems },
52+
{ trigger: "@", items: fileItems },
53+
],
54+
[fileItems, skillItems],
4055
);
4156

4257
const {
@@ -58,19 +73,28 @@ export function useComposerAutocompleteState({
5873
if (!autocompleteRange) {
5974
return;
6075
}
61-
const before = text.slice(0, autocompleteRange.start);
76+
const triggerIndex = Math.max(0, autocompleteRange.start - 1);
77+
const triggerChar = text[triggerIndex] ?? "";
78+
const before =
79+
triggerChar === "@"
80+
? text.slice(0, triggerIndex)
81+
: text.slice(0, autocompleteRange.start);
6282
const after = text.slice(autocompleteRange.end);
6383
const insert = item.insertText ?? item.label;
84+
const actualInsert = triggerChar === "@"
85+
? insert.replace(/^@+/, "")
86+
: insert;
6487
const needsSpace = after.length === 0 ? true : !/^\s/.test(after);
65-
const nextText = `${before}${insert}${needsSpace ? " " : ""}${after}`;
88+
const nextText = `${before}${actualInsert}${needsSpace ? " " : ""}${after}`;
6689
setText(nextText);
6790
closeAutocomplete();
6891
requestAnimationFrame(() => {
6992
const textarea = textareaRef.current;
7093
if (!textarea) {
7194
return;
7295
}
73-
const cursor = before.length + insert.length + (needsSpace ? 1 : 0);
96+
const cursor =
97+
before.length + actualInsert.length + (needsSpace ? 1 : 0);
7498
textarea.focus();
7599
textarea.setSelectionRange(cursor, cursor);
76100
setSelectionStart(cursor);

src/hooks/useWorkspaceFiles.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2+
import type { DebugEntry, WorkspaceInfo } from "../types";
3+
import { getWorkspaceFiles } from "../services/tauri";
4+
5+
type UseWorkspaceFilesOptions = {
6+
activeWorkspace: WorkspaceInfo | null;
7+
onDebug?: (entry: DebugEntry) => void;
8+
};
9+
10+
export function useWorkspaceFiles({
11+
activeWorkspace,
12+
onDebug,
13+
}: UseWorkspaceFilesOptions) {
14+
const [files, setFiles] = useState<string[]>([]);
15+
const lastFetchedWorkspaceId = useRef<string | null>(null);
16+
const inFlight = useRef(false);
17+
18+
const workspaceId = activeWorkspace?.id ?? null;
19+
const isConnected = Boolean(activeWorkspace?.connected);
20+
21+
const refreshFiles = useCallback(async () => {
22+
if (!workspaceId || !isConnected) {
23+
return;
24+
}
25+
if (inFlight.current) {
26+
return;
27+
}
28+
inFlight.current = true;
29+
onDebug?.({
30+
id: `${Date.now()}-client-files-list`,
31+
timestamp: Date.now(),
32+
source: "client",
33+
label: "files/list",
34+
payload: { workspaceId },
35+
});
36+
try {
37+
const response = await getWorkspaceFiles(workspaceId);
38+
onDebug?.({
39+
id: `${Date.now()}-server-files-list`,
40+
timestamp: Date.now(),
41+
source: "server",
42+
label: "files/list response",
43+
payload: response,
44+
});
45+
setFiles(Array.isArray(response) ? response : []);
46+
lastFetchedWorkspaceId.current = workspaceId;
47+
} catch (error) {
48+
onDebug?.({
49+
id: `${Date.now()}-client-files-list-error`,
50+
timestamp: Date.now(),
51+
source: "error",
52+
label: "files/list error",
53+
payload: error instanceof Error ? error.message : String(error),
54+
});
55+
} finally {
56+
inFlight.current = false;
57+
}
58+
}, [isConnected, onDebug, workspaceId]);
59+
60+
useEffect(() => {
61+
if (!workspaceId || !isConnected) {
62+
return;
63+
}
64+
if (lastFetchedWorkspaceId.current === workspaceId && files.length > 0) {
65+
return;
66+
}
67+
refreshFiles();
68+
}, [files.length, isConnected, refreshFiles, workspaceId]);
69+
70+
const fileOptions = useMemo(() => files.filter(Boolean), [files]);
71+
72+
return {
73+
files: fileOptions,
74+
refreshFiles,
75+
};
76+
}

src/services/tauri.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,10 @@ export async function getSkillsList(workspaceId: string) {
132132
return invoke<any>("skills_list", { workspaceId });
133133
}
134134

135+
export async function getWorkspaceFiles(workspaceId: string) {
136+
return invoke<string[]>("list_workspace_files", { workspaceId });
137+
}
138+
135139
export async function listThreads(
136140
workspaceId: string,
137141
cursor?: string | null,

0 commit comments

Comments
 (0)