Skip to content

Commit e3f2d6b

Browse files
authored
feat: rich text editor with file tagging (#21)
1 parent e90cd6d commit e3f2d6b

File tree

13 files changed

+2356
-101
lines changed

13 files changed

+2356
-101
lines changed

package.json

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,8 +62,17 @@
6262
"yaml": "^2.8.1"
6363
},
6464
"dependencies": {
65+
"@tiptap/extension-link": "^3.6.6",
66+
"@tiptap/extension-mention": "^3.6.6",
67+
"@tiptap/extension-placeholder": "^3.6.6",
68+
"@tiptap/extension-typography": "^3.6.6",
69+
"@tiptap/extension-underline": "^3.6.6",
70+
"@tiptap/pm": "^3.6.6",
71+
"@tiptap/react": "^3.6.6",
72+
"@tiptap/starter-kit": "^3.6.6",
73+
"@tiptap/suggestion": "^3.6.6",
6574
"@phosphor-icons/react": "^2.1.10",
66-
"@posthog/agent": "^1.4.0",
75+
"@posthog/agent": "^1.6.0",
6776
"@radix-ui/react-icons": "^1.3.2",
6877
"@radix-ui/themes": "^3.2.1",
6978
"@tanstack/react-query": "^5.90.2",

pnpm-lock.yaml

Lines changed: 656 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/main/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
shell,
1111
} from "electron";
1212
import { registerAgentIpc, type TaskController } from "./services/agent.js";
13+
import { registerFsIpc } from "./services/fs.js";
1314
import { registerOsIpc } from "./services/os.js";
1415
import { registerPosthogIpc } from "./services/posthog.js";
1516

@@ -142,3 +143,4 @@ app.on("activate", () => {
142143
registerPosthogIpc();
143144
registerOsIpc(() => mainWindow);
144145
registerAgentIpc(taskControllers, () => mainWindow);
146+
registerFsIpc();

src/main/preload.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,11 @@ contextBridge.exposeInMainWorld("electronAPI", {
3838
ipcRenderer.invoke("show-message-box", options),
3939
openExternal: (url: string): Promise<void> =>
4040
ipcRenderer.invoke("open-external", url),
41+
listRepoFiles: (
42+
repoPath: string,
43+
query?: string,
44+
): Promise<Array<{ path: string; name: string }>> =>
45+
ipcRenderer.invoke("list-repo-files", repoPath, query),
4146
agentStart: async (
4247
params: AgentStartParams,
4348
): Promise<{ taskId: string; channel: string }> =>

src/main/services/fs.ts

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import { exec } from "node:child_process";
2+
import fs from "node:fs";
3+
import path from "node:path";
4+
import { promisify } from "node:util";
5+
import { type IpcMainInvokeEvent, ipcMain } from "electron";
6+
7+
const execAsync = promisify(exec);
8+
const fsPromises = fs.promises;
9+
10+
interface FileEntry {
11+
path: string;
12+
name: string;
13+
}
14+
15+
// Cache for repository files to avoid rescanning
16+
const repoFileCache = new Map<
17+
string,
18+
{ files: FileEntry[]; timestamp: number }
19+
>();
20+
const CACHE_TTL = 30000; // 30 seconds
21+
22+
async function getGitIgnoredFiles(repoPath: string): Promise<Set<string>> {
23+
try {
24+
const { stdout } = await execAsync(
25+
"git ls-files --others --ignored --exclude-standard",
26+
{ cwd: repoPath },
27+
);
28+
return new Set(
29+
stdout
30+
.split("\n")
31+
.filter(Boolean)
32+
.map((f) => path.join(repoPath, f)),
33+
);
34+
} catch {
35+
// If git command fails, return empty set
36+
return new Set();
37+
}
38+
}
39+
40+
async function listFilesRecursive(
41+
dirPath: string,
42+
ignoredFiles: Set<string>,
43+
baseDir: string,
44+
): Promise<FileEntry[]> {
45+
const files: FileEntry[] = [];
46+
47+
try {
48+
const entries = await fsPromises.readdir(dirPath, { withFileTypes: true });
49+
50+
for (const entry of entries) {
51+
const fullPath = path.join(dirPath, entry.name);
52+
const relativePath = path.relative(baseDir, fullPath);
53+
54+
// Skip hidden files/directories, node_modules, and common build dirs
55+
if (
56+
entry.name.startsWith(".") ||
57+
entry.name === "node_modules" ||
58+
entry.name === "dist" ||
59+
entry.name === "build" ||
60+
entry.name === "__pycache__"
61+
) {
62+
continue;
63+
}
64+
65+
// Skip git-ignored files
66+
if (ignoredFiles.has(fullPath)) {
67+
continue;
68+
}
69+
70+
if (entry.isDirectory()) {
71+
const subFiles = await listFilesRecursive(
72+
fullPath,
73+
ignoredFiles,
74+
baseDir,
75+
);
76+
files.push(...subFiles);
77+
} else if (entry.isFile()) {
78+
files.push({
79+
path: relativePath,
80+
name: entry.name,
81+
});
82+
}
83+
}
84+
} catch (error) {
85+
// Skip directories we can't read
86+
console.error(`Error reading directory ${dirPath}:`, error);
87+
}
88+
89+
return files;
90+
}
91+
92+
export function registerFsIpc(): void {
93+
ipcMain.handle(
94+
"list-repo-files",
95+
async (
96+
_event: IpcMainInvokeEvent,
97+
repoPath: string,
98+
query?: string,
99+
): Promise<FileEntry[]> => {
100+
if (!repoPath) return [];
101+
102+
try {
103+
// Check cache
104+
const cached = repoFileCache.get(repoPath);
105+
const now = Date.now();
106+
107+
let allFiles: FileEntry[];
108+
109+
if (cached && now - cached.timestamp < CACHE_TTL) {
110+
allFiles = cached.files;
111+
} else {
112+
// Get git-ignored files
113+
const ignoredFiles = await getGitIgnoredFiles(repoPath);
114+
115+
// List all files
116+
allFiles = await listFilesRecursive(repoPath, ignoredFiles, repoPath);
117+
118+
// Update cache
119+
repoFileCache.set(repoPath, {
120+
files: allFiles,
121+
timestamp: now,
122+
});
123+
}
124+
125+
// Filter by query if provided
126+
if (query?.trim()) {
127+
const lowerQuery = query.toLowerCase();
128+
return allFiles
129+
.filter(
130+
(f) =>
131+
f.path.toLowerCase().includes(lowerQuery) ||
132+
f.name.toLowerCase().includes(lowerQuery),
133+
)
134+
.slice(0, 50); // Limit results
135+
}
136+
137+
return allFiles.slice(0, 100); // Limit initial results
138+
} catch (error) {
139+
console.error("Error listing repo files:", error);
140+
return [];
141+
}
142+
},
143+
);
144+
}
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
import { Box, Flex, Text } from "@radix-ui/themes";
2+
import type { SuggestionKeyDownProps } from "@tiptap/suggestion";
3+
import {
4+
type ForwardedRef,
5+
forwardRef,
6+
useEffect,
7+
useImperativeHandle,
8+
useRef,
9+
useState,
10+
} from "react";
11+
12+
export interface FileMentionListProps {
13+
items: Array<{ path: string; name: string }>;
14+
command: (item: { id: string; label: string }) => void;
15+
}
16+
17+
export interface FileMentionListRef {
18+
onKeyDown: (props: SuggestionKeyDownProps) => boolean;
19+
}
20+
21+
export const FileMentionList = forwardRef(
22+
(props: FileMentionListProps, ref: ForwardedRef<FileMentionListRef>) => {
23+
const [selectedIndex, setSelectedIndex] = useState(0);
24+
const containerRef = useRef<HTMLDivElement>(null);
25+
const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
26+
27+
const scrollIntoView = (index: number) => {
28+
const container = containerRef.current;
29+
const item = itemRefs.current[index];
30+
31+
if (!container || !item) return;
32+
33+
const containerTop = container.scrollTop;
34+
const containerBottom = containerTop + container.clientHeight;
35+
36+
const itemTop = item.offsetTop;
37+
const itemBottom = itemTop + item.offsetHeight;
38+
39+
if (itemTop < containerTop) {
40+
// Item is above visible area
41+
container.scrollTop = itemTop;
42+
} else if (itemBottom > containerBottom) {
43+
// Item is below visible area
44+
container.scrollTop = itemBottom - container.clientHeight;
45+
}
46+
};
47+
48+
const selectItem = (index: number) => {
49+
const item = props.items[index];
50+
if (item) {
51+
props.command({ id: item.path, label: item.name });
52+
}
53+
};
54+
55+
const upHandler = () => {
56+
const newIndex =
57+
(selectedIndex + props.items.length - 1) % props.items.length;
58+
setSelectedIndex(newIndex);
59+
setTimeout(() => scrollIntoView(newIndex), 0);
60+
};
61+
62+
const downHandler = () => {
63+
const newIndex = (selectedIndex + 1) % props.items.length;
64+
setSelectedIndex(newIndex);
65+
setTimeout(() => scrollIntoView(newIndex), 0);
66+
};
67+
68+
const enterHandler = () => {
69+
selectItem(selectedIndex);
70+
};
71+
72+
useEffect(() => setSelectedIndex(0), []);
73+
74+
useEffect(() => {
75+
// Initialize refs array to match items length
76+
itemRefs.current = itemRefs.current.slice(0, props.items.length);
77+
}, [props.items.length]);
78+
79+
useImperativeHandle(ref, () => ({
80+
onKeyDown: ({ event }: SuggestionKeyDownProps) => {
81+
if (event.key === "ArrowUp") {
82+
upHandler();
83+
return true;
84+
}
85+
86+
if (event.key === "ArrowDown") {
87+
downHandler();
88+
return true;
89+
}
90+
91+
if (event.key === "Enter") {
92+
enterHandler();
93+
return true;
94+
}
95+
96+
return false;
97+
},
98+
}));
99+
100+
if (props.items.length === 0) {
101+
return (
102+
<Box
103+
className="file-mention-list"
104+
style={{
105+
background: "var(--color-panel-solid)",
106+
border: "1px solid var(--gray-a6)",
107+
borderRadius: "var(--radius-2)",
108+
boxShadow: "var(--shadow-5)",
109+
maxHeight: "300px",
110+
overflow: "auto",
111+
padding: "var(--space-2)",
112+
}}
113+
>
114+
<Text size="2" color="gray">
115+
No files found
116+
</Text>
117+
</Box>
118+
);
119+
}
120+
121+
return (
122+
<Box
123+
ref={containerRef}
124+
className="file-mention-list"
125+
style={{
126+
background: "var(--color-panel-solid)",
127+
border: "1px solid var(--gray-a6)",
128+
borderRadius: "var(--radius-2)",
129+
boxShadow: "var(--shadow-5)",
130+
maxHeight: "300px",
131+
overflow: "auto",
132+
}}
133+
>
134+
{props.items.map((item, index) => (
135+
<Flex
136+
key={item.path}
137+
ref={(el) => {
138+
itemRefs.current[index] = el;
139+
}}
140+
className={`file-mention-item ${index === selectedIndex ? "is-selected" : ""}`}
141+
onClick={() => selectItem(index)}
142+
onMouseEnter={() => setSelectedIndex(index)}
143+
>
144+
<Flex direction="column" gap="1">
145+
<Text size="1">{item.path}</Text>
146+
</Flex>
147+
</Flex>
148+
))}
149+
</Box>
150+
);
151+
},
152+
);
153+
154+
FileMentionList.displayName = "FileMentionList";

0 commit comments

Comments
 (0)