Skip to content

Commit bcfe3d7

Browse files
authored
feat: add fuzzy search file picker (#571)
1 parent 37cdc19 commit bcfe3d7

9 files changed

Lines changed: 417 additions & 7 deletions

File tree

apps/twig/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@
128128
"electron-store": "^11.0.0",
129129
"file-icon": "^6.0.0",
130130
"framer-motion": "^12.26.2",
131+
"fzf": "^0.5.2",
131132
"idb-keyval": "^6.2.2",
132133
"ignore": "^7.0.5",
133134
"immer": "^11.0.1",

apps/twig/src/main/services/fs/service.ts

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,11 @@ import { exec } from "node:child_process";
22
import fs from "node:fs";
33
import path from "node:path";
44
import { promisify } from "node:util";
5-
import { injectable } from "inversify";
5+
import { inject, injectable } from "inversify";
6+
import { MAIN_TOKENS } from "../../di/tokens.js";
67
import { logger } from "../../lib/logger.js";
8+
import { FileWatcherEvent } from "../file-watcher/schemas.js";
9+
import type { FileWatcherService } from "../file-watcher/service.js";
710
import { getChangedFilesForRepo } from "../git.js";
811
import type { FileEntry } from "./schemas.js";
912

@@ -15,10 +18,31 @@ export class FsService {
1518
private static readonly CACHE_TTL = 30000;
1619
private cache = new Map<string, { files: FileEntry[]; timestamp: number }>();
1720

21+
constructor(
22+
@inject(MAIN_TOKENS.FileWatcherService)
23+
private fileWatcher: FileWatcherService,
24+
) {
25+
this.fileWatcher.on(FileWatcherEvent.FileChanged, ({ repoPath }) => {
26+
this.invalidateCache(repoPath);
27+
});
28+
29+
this.fileWatcher.on(FileWatcherEvent.FileDeleted, ({ repoPath }) => {
30+
this.invalidateCache(repoPath);
31+
});
32+
33+
this.fileWatcher.on(FileWatcherEvent.DirectoryChanged, ({ repoPath }) => {
34+
this.invalidateCache(repoPath);
35+
});
36+
37+
this.fileWatcher.on(FileWatcherEvent.GitStateChanged, ({ repoPath }) => {
38+
this.invalidateCache(repoPath);
39+
});
40+
}
41+
1842
async listRepoFiles(
1943
repoPath: string,
2044
query?: string,
21-
limit = 50,
45+
limit?: number,
2246
): Promise<FileEntry[]> {
2347
if (!repoPath) return [];
2448

@@ -32,20 +56,28 @@ export class FsService {
3256

3357
const cached = this.cache.get(repoPath);
3458
if (cached && Date.now() - cached.timestamp < FsService.CACHE_TTL) {
35-
return cached.files.slice(0, limit);
59+
return limit ? cached.files.slice(0, limit) : cached.files;
3660
}
3761

3862
const files = await this.gitLsFiles(repoPath);
3963
const entries = this.toFileEntries(files, changedFiles);
4064
this.cache.set(repoPath, { files: entries, timestamp: Date.now() });
4165

42-
return entries.slice(0, limit);
66+
return limit ? entries.slice(0, limit) : entries;
4367
} catch (error) {
4468
log.error("Error listing repo files:", error);
4569
return [];
4670
}
4771
}
4872

73+
invalidateCache(repoPath?: string): void {
74+
if (repoPath) {
75+
this.cache.delete(repoPath);
76+
} else {
77+
this.cache.clear();
78+
}
79+
}
80+
4981
async readRepoFile(
5082
repoPath: string,
5183
filePath: string,
@@ -73,7 +105,7 @@ export class FsService {
73105
content,
74106
"utf-8",
75107
);
76-
this.cache.delete(repoPath);
108+
this.invalidateCache(repoPath);
77109
}
78110

79111
private resolvePath(repoPath: string, filePath: string): string {
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
.command-dialog-overlay {
2+
position: fixed;
3+
inset: 0;
4+
z-index: 50;
5+
background-color: rgba(0, 0, 0, 0.2);
6+
}
7+
8+
.command-dialog-content {
9+
position: fixed;
10+
top: var(--space-9);
11+
left: 50%;
12+
z-index: 50;
13+
transform: translateX(-50%);
14+
width: 640px;
15+
max-width: 90vw;
16+
overflow: hidden;
17+
border-radius: var(--radius-2);
18+
border: 1px solid var(--gray-6);
19+
background-color: var(--gray-1);
20+
box-shadow: var(--shadow-6);
21+
}

apps/twig/src/renderer/features/command/components/Command.tsx

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Command as CmdkCommand } from "cmdk";
22
import React from "react";
3+
import "./Command.css";
34

45
interface CommandRootProps extends React.ComponentProps<typeof CmdkCommand> {
56
className?: string;
@@ -122,8 +123,36 @@ const CommandEmpty = React.forwardRef<
122123

123124
CommandEmpty.displayName = "CommandEmpty";
124125

126+
interface CommandDialogProps
127+
extends React.ComponentProps<typeof CmdkCommand.Dialog> {
128+
className?: string;
129+
contentClassName?: string;
130+
}
131+
132+
const CommandDialog = ({
133+
className,
134+
contentClassName,
135+
children,
136+
...props
137+
}: CommandDialogProps) => {
138+
return (
139+
<CmdkCommand.Dialog
140+
label="Command menu"
141+
className={className}
142+
contentClassName={`command-dialog-content ${contentClassName || ""}`}
143+
overlayClassName="command-dialog-overlay"
144+
{...props}
145+
>
146+
{children}
147+
</CmdkCommand.Dialog>
148+
);
149+
};
150+
151+
CommandDialog.displayName = "CommandDialog";
152+
125153
export const Command = {
126154
Root: CommandRoot,
155+
Dialog: CommandDialog,
127156
Input: CommandInput,
128157
List: CommandList,
129158
Item: CommandItem,
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
.file-picker-popover [cmdk-root] {
2+
width: 640px;
3+
min-width: 640px;
4+
background: var(--color-panel-solid);
5+
border-radius: var(--radius-3);
6+
border: 1px solid var(--gray-6);
7+
box-shadow: var(--shadow-6);
8+
overflow: hidden;
9+
}
10+
11+
.file-picker-popover [cmdk-input] {
12+
font-family: var(--default-font-family);
13+
font-size: 13px;
14+
padding: 12px 16px;
15+
width: 100%;
16+
background: transparent;
17+
border: none;
18+
outline: none;
19+
color: var(--gray-12);
20+
caret-color: var(--accent-9);
21+
}
22+
23+
.file-picker-popover [cmdk-input]::placeholder {
24+
color: var(--gray-9);
25+
}
26+
27+
.file-picker-popover [cmdk-list] {
28+
max-height: 400px;
29+
overflow-y: auto;
30+
overflow-x: hidden;
31+
overscroll-behavior: contain;
32+
transition: height 150ms ease;
33+
}
34+
35+
.file-picker-popover [cmdk-list]::-webkit-scrollbar {
36+
width: 8px;
37+
}
38+
39+
.file-picker-popover [cmdk-list]::-webkit-scrollbar-track {
40+
background: transparent;
41+
}
42+
43+
.file-picker-popover [cmdk-list]::-webkit-scrollbar-thumb {
44+
background: var(--gray-6);
45+
border-radius: 4px;
46+
}
47+
48+
.file-picker-popover [cmdk-list]::-webkit-scrollbar-thumb:hover {
49+
background: var(--gray-7);
50+
}
51+
52+
.file-picker-popover [cmdk-group] {
53+
padding: 4px 8px;
54+
}
55+
56+
.file-picker-popover [cmdk-group-heading] {
57+
font-size: 11px;
58+
font-weight: 500;
59+
color: var(--gray-11);
60+
padding: 8px 12px 4px;
61+
text-transform: uppercase;
62+
letter-spacing: 0.05em;
63+
}
64+
65+
.file-picker-popover [cmdk-item] {
66+
display: flex;
67+
align-items: center;
68+
gap: 8px;
69+
padding: 8px 12px;
70+
border-radius: var(--radius-2);
71+
cursor: pointer;
72+
user-select: none;
73+
transition: background-color 150ms ease;
74+
color: var(--gray-12);
75+
}
76+
77+
.file-picker-popover [cmdk-item][data-selected="true"] {
78+
background: var(--accent-3);
79+
color: var(--accent-11);
80+
}
81+
82+
.file-picker-popover [cmdk-item][data-disabled="true"] {
83+
opacity: 0.5;
84+
cursor: not-allowed;
85+
pointer-events: none;
86+
}
87+
88+
.file-picker-popover [cmdk-item]:active {
89+
background: var(--accent-4);
90+
}
91+
92+
.file-picker-popover [cmdk-empty] {
93+
display: flex;
94+
align-items: center;
95+
justify-content: center;
96+
height: 64px;
97+
font-size: 13px;
98+
color: var(--gray-9);
99+
}
100+
101+
.file-picker-popover [cmdk-separator] {
102+
height: 1px;
103+
background: var(--gray-6);
104+
margin: 4px 0;
105+
}
106+
107+
.rt-PopoverContent:has(.file-picker-popover) {
108+
padding: 0 !important;
109+
background: transparent !important;
110+
border: none !important;
111+
box-shadow: none !important;
112+
}

0 commit comments

Comments
 (0)