Compare commits

...

4 Commits

Author SHA1 Message Date
Dax Raad
8056842baf sync 2026-02-12 18:19:14 -05:00
Dax Raad
8ea1df1560 fix(file): honor dirs=false in route 2026-02-10 23:18:32 -05:00
Dax Raad
52ae62d4cc feat(file): simplify search type param 2026-02-10 23:16:26 -05:00
Dax Raad
3c11eda350 feat(file): use FFF for file searches 2026-02-10 23:03:26 -05:00
6 changed files with 156 additions and 28 deletions

View File

@@ -288,6 +288,7 @@
"@ai-sdk/vercel": "1.0.33",
"@ai-sdk/xai": "2.0.51",
"@clack/prompts": "1.0.0-alpha.1",
"@ff-labs/bun": "0.1.37",
"@gitlab/gitlab-ai-provider": "3.5.0",
"@gitlab/opencode-gitlab-auth": "1.3.2",
"@hono/standard-validator": "0.1.5",
@@ -961,6 +962,8 @@
"@fastify/rate-limit": ["@fastify/rate-limit@10.3.0", "", { "dependencies": { "@lukeed/ms": "^2.0.2", "fastify-plugin": "^5.0.0", "toad-cache": "^3.7.0" } }, "sha512-eIGkG9XKQs0nyynatApA3EVrojHOuq4l6fhB4eeCk4PIOeadvOJz9/4w3vGI44Go17uaXOWEcPkaD8kuKm7g6Q=="],
"@ff-labs/bun": ["@ff-labs/bun@0.1.37", "", { "peerDependencies": { "bun": ">=1.0.0" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "fff": "scripts/cli.ts", "fff-demo": "examples/search.ts" } }, "sha512-kDdRSLXCKgZjqVQoVAy2SaXAtAKUsg1GXgYGYTQYGbLcqS1j8oBRA5abnDikamgFbH4QHMC/ml1fQTXUN94TUQ=="],
"@floating-ui/core": ["@floating-ui/core@1.7.4", "", { "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg=="],
"@floating-ui/dom": ["@floating-ui/dom@1.7.5", "", { "dependencies": { "@floating-ui/core": "^1.7.4", "@floating-ui/utils": "^0.2.10" } }, "sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg=="],
@@ -1305,6 +1308,28 @@
"@oslojs/jwt": ["@oslojs/jwt@0.2.0", "", { "dependencies": { "@oslojs/encoding": "0.4.1" } }, "sha512-bLE7BtHrURedCn4Mco3ma9L4Y1GR2SMBuIvjWr7rmQ4/W/4Jy70TIAgZ+0nIlk0xHz1vNP8x8DCns45Sb2XRbg=="],
"@oven/bun-darwin-aarch64": ["@oven/bun-darwin-aarch64@1.3.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-df7smckMWSUfaT5mzwN9Lfpd3ZGkOqo+vmQ8VV2a32gl14v6uZ/qeeo+1RlANXn8M0uzXPWWCkrKZIWSZUR0qw=="],
"@oven/bun-darwin-x64": ["@oven/bun-darwin-x64@1.3.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-YiLxfsPzQqaVvT2a+nxH9do0YfUjrlxF3tKP0b1DDgvfgCcVKGsrQH3Wa82qHgL4dnT8h2bqi94JxXESEuPmcA=="],
"@oven/bun-darwin-x64-baseline": ["@oven/bun-darwin-x64-baseline@1.3.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-XbhsA2XAFzvFr0vPSV6SNqGxab4xHKdPmVTLqoSHAx9tffrSq/012BDptOskulwnD+YNsrJUx2D2Ve1xvfgGcg=="],
"@oven/bun-linux-aarch64": ["@oven/bun-linux-aarch64@1.3.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-VaNQTu0Up4gnwZLQ6/Hmho6jAlLxTQ1PwxEth8EsXHf82FOXXPV5OCQ6KC9mmmocjKlmWFaIGebThrOy8DUo4g=="],
"@oven/bun-linux-aarch64-musl": ["@oven/bun-linux-aarch64-musl@1.3.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-t8uimCVBTw5f9K2QTZE5wN6UOrFETNrh/Xr7qtXT9nAOzaOnIFvYA+HcHbGfi31fRlCVfTxqm/EiCwJ1gEw9YQ=="],
"@oven/bun-linux-x64": ["@oven/bun-linux-x64@1.3.9", "", { "os": "linux", "cpu": "x64" }, "sha512-oQyAW3+ugulvXTZ+XYeUMmNPR94sJeMokfHQoKwPvVwhVkgRuMhcLGV2ZesHCADVu30Oz2MFXbgdC8x4/o9dRg=="],
"@oven/bun-linux-x64-baseline": ["@oven/bun-linux-x64-baseline@1.3.9", "", { "os": "linux", "cpu": "x64" }, "sha512-nZ12g22cy7pEOBwAxz2tp0wVqekaCn9QRKuGTHqOdLlyAqR4SCdErDvDhUWd51bIyHTQoCmj72TegGTgG0WNPw=="],
"@oven/bun-linux-x64-musl": ["@oven/bun-linux-x64-musl@1.3.9", "", { "os": "linux", "cpu": "x64" }, "sha512-4ZjIUgCxEyKwcKXideB5sX0KJpnHTZtu778w73VNq2uNH2fNpMZv98+DBgJyQ9OfFoRhmKn1bmLmSefvnHzI9w=="],
"@oven/bun-linux-x64-musl-baseline": ["@oven/bun-linux-x64-musl-baseline@1.3.9", "", { "os": "linux", "cpu": "x64" }, "sha512-3FXQgtYFsT0YOmAdMcJn56pLM5kzSl6y942rJJIl5l2KummB9Ea3J/vMJMzQk7NCAGhleZGWU/pJSS/uXKGa7w=="],
"@oven/bun-windows-x64": ["@oven/bun-windows-x64@1.3.9", "", { "os": "win32", "cpu": "x64" }, "sha512-/d6vAmgKvkoYlsGPsRPlPmOK1slPis/F40UG02pYwypTH0wmY0smgzdFqR4YmryxFh17XrW1kITv+U99Oajk9Q=="],
"@oven/bun-windows-x64-baseline": ["@oven/bun-windows-x64-baseline@1.3.9", "", { "os": "win32", "cpu": "x64" }, "sha512-a/+hSrrDpMD7THyXvE2KJy1skxzAD0cnW4K1WjuI/91VqsphjNzvf5t/ZgxEVL4wb6f+hKrSJ5J3aH47zPr61g=="],
"@oxc-minify/binding-android-arm64": ["@oxc-minify/binding-android-arm64@0.96.0", "", { "os": "android", "cpu": "arm64" }, "sha512-lzeIEMu/v6Y+La5JSesq4hvyKtKBq84cgQpKYTYM/yGuNk2tfd5Ha31hnC+mTh48lp/5vZH+WBfjVUjjINCfug=="],
"@oxc-minify/binding-darwin-arm64": ["@oxc-minify/binding-darwin-arm64@0.96.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-i0LkJAUXb4BeBFrJQbMKQPoxf8+cFEffDyLSb7NEzzKuPcH8qrVsnEItoOzeAdYam8Sr6qCHVwmBNEQzl7PWpw=="],
@@ -2177,6 +2202,8 @@
"buffers": ["buffers@0.1.1", "", {}, "sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ=="],
"bun": ["bun@1.3.9", "", { "optionalDependencies": { "@oven/bun-darwin-aarch64": "1.3.9", "@oven/bun-darwin-x64": "1.3.9", "@oven/bun-darwin-x64-baseline": "1.3.9", "@oven/bun-linux-aarch64": "1.3.9", "@oven/bun-linux-aarch64-musl": "1.3.9", "@oven/bun-linux-x64": "1.3.9", "@oven/bun-linux-x64-baseline": "1.3.9", "@oven/bun-linux-x64-musl": "1.3.9", "@oven/bun-linux-x64-musl-baseline": "1.3.9", "@oven/bun-windows-x64": "1.3.9", "@oven/bun-windows-x64-baseline": "1.3.9" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "bun": "bin/bun.exe", "bunx": "bin/bunx.exe" } }, "sha512-v5hkh1us7sMNjfimWE70flYbD5I1/qWQaqmJ45q2qk5H/7muQVa478LSVRSFyGTBUBog2LsPQnfIRdjyWJRY+A=="],
"bun-ffi-structs": ["bun-ffi-structs@0.1.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-Lh1oQAYHDcnesJauieA4UNkWGXY9hYck7OA5IaRwE3Bp6K2F2pJSNYqq+hIy7P3uOvo3km3oxS8304g5gDMl/w=="],
"bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="],

View File

@@ -71,6 +71,7 @@
"@ai-sdk/vercel": "1.0.33",
"@ai-sdk/xai": "2.0.51",
"@clack/prompts": "1.0.0-alpha.1",
"@ff-labs/bun": "0.1.37",
"@gitlab/gitlab-ai-provider": "3.5.0",
"@gitlab/opencode-gitlab-auth": "1.3.2",
"@hono/standard-validator": "0.1.5",

View File

@@ -0,0 +1,29 @@
import { FileFinder } from "@ff-labs/bun"
import { Log } from "@/util/log"
export namespace FFF {
const log = Log.create({ service: "file.fff" })
const init = (cwd: string) => {
const result = FileFinder.init({ basePath: cwd })
if (result.ok) return true
log.error("init failed", { error: result.error, cwd })
return false
}
export async function search(input: { cwd: string; query: string; limit: number }) {
if (!input.query) return []
if (!init(input.cwd)) return []
const result = FileFinder.search(input.query, {
pageIndex: 0,
pageSize: input.limit,
})
if (!result.ok) {
log.error("search failed", { error: result.error, query: input.query, cwd: input.cwd })
return []
}
return result.value.items.map((item: { relativePath: string }) => item.relativePath)
}
}

View File

@@ -9,6 +9,7 @@ import ignore from "ignore"
import { Log } from "../util/log"
import { Filesystem } from "../util/filesystem"
import { Instance } from "../project/instance"
import { FFF } from "./fff"
import { Ripgrep } from "./ripgrep"
import fuzzysort from "fuzzysort"
import { Global } from "../global"
@@ -541,42 +542,61 @@ export namespace File {
})
}
export async function search(input: { query: string; limit?: number; dirs?: boolean; type?: "file" | "directory" }) {
export async function search(input: { query: string; limit?: number; type?: "file" | "directory" | "all" }) {
const query = input.query.trim()
const limit = input.limit ?? 100
const kind = input.type ?? (input.dirs === false ? "file" : "all")
const kind = input.type ?? "all"
log.info("search", { query, kind })
const result = await state().then((x) => x.files())
const hidden = (item: string) => {
const normalized = item.replaceAll("\\", "/").replace(/\/+$/, "")
return normalized.split("/").some((p) => p.startsWith(".") && p.length > 1)
}
const preferHidden = query.startsWith(".") || query.includes("/.")
const sortHiddenLast = (items: string[]) => {
if (preferHidden) return items
const visible: string[] = []
const hiddenItems: string[] = []
for (const item of items) {
const isHidden = hidden(item)
if (isHidden) hiddenItems.push(item)
if (!isHidden) visible.push(item)
const files = await FFF.search({
cwd: Instance.directory,
query,
limit: kind === "all" || kind === "directory" ? limit * 20 : limit,
})
const set = new Set<string>()
for (const file of files) {
let dir = path.dirname(file)
while (true) {
if (dir === ".") break
const next = path.dirname(dir)
set.add(dir + "/")
if (next === dir) break
dir = next
}
return [...visible, ...hiddenItems]
}
const allDirs = Array.from(set)
if (!query) {
if (kind === "file") return result.files.slice(0, limit)
return sortHiddenLast(result.dirs.toSorted()).slice(0, limit)
const output = kind === "file" ? files.slice(0, limit) : allDirs.toSorted().slice(0, limit)
log.info("search", { query, kind, results: output.length })
return output
}
const items =
kind === "file" ? result.files : kind === "directory" ? result.dirs : [...result.files, ...result.dirs]
if (kind === "directory") {
const ranked: string[] = []
for (const item of fuzzysort.go(query, allDirs, { limit: limit * 20 })) {
ranked.push(item.target)
}
const output = ranked.slice(0, limit)
log.info("search", { query, kind, results: output.length })
return output
}
const searchLimit = kind === "directory" && !preferHidden ? limit * 20 : limit
const sorted = fuzzysort.go(query, items, { limit: searchLimit }).map((r) => r.target)
const output = kind === "directory" ? sortHiddenLast(sorted).slice(0, limit) : sorted
if (kind === "file") {
const output = files.slice(0, limit)
log.info("search", { query, kind, results: output.length })
return output
}
const rankedDirs: string[] = []
for (const item of fuzzysort.go(query, allDirs, { limit })) {
rankedDirs.push(item.target)
}
const merged = files.slice(0, limit).concat(rankedDirs)
const output: string[] = []
for (const item of fuzzysort.go(query, merged, { limit })) {
output.push(item.target)
}
log.info("search", { query, kind, results: output.length })
return output
}

View File

@@ -64,7 +64,7 @@ export const FileRoutes = lazy(() =>
z.object({
query: z.string(),
dirs: z.enum(["true", "false"]).optional(),
type: z.enum(["file", "directory"]).optional(),
type: z.enum(["file", "directory", "all"]).optional(),
limit: z.coerce.number().int().min(1).max(200).optional(),
}),
),
@@ -76,8 +76,7 @@ export const FileRoutes = lazy(() =>
const results = await File.search({
query,
limit: limit ?? 10,
dirs: dirs !== "false",
type,
type: dirs === "false" ? "file" : type,
})
return c.json(results)
},

View File

@@ -0,0 +1,52 @@
import { describe, expect, test } from "bun:test"
import path from "path"
import { tmpdir } from "../fixture/fixture"
import { FFF } from "../../src/file/fff"
import { File } from "../../src/file"
import { Instance } from "../../src/project/instance"
describe("file.fff", () => {
test("returns files and supports directory search via File.search", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "src", "app", "index.ts"), "export const app = true")
await Bun.write(path.join(dir, "src", "app", "util.ts"), "export const util = true")
await Bun.write(path.join(dir, "docs", "guide.md"), "# guide")
},
})
const files = await FFF.search({
cwd: tmp.path,
query: "index",
limit: 20,
})
expect(files.includes(path.join("src", "app", "index.ts"))).toBe(true)
await Instance.provide({
directory: tmp.path,
fn: async () => {
const found = await File.search({
query: "index",
type: "file",
limit: 20,
})
expect(found.includes(path.join("src", "app", "index.ts"))).toBe(true)
const dirs = await File.search({
query: "app",
type: "directory",
limit: 20,
})
expect(dirs.includes("src/app/")).toBe(true)
const all = await File.search({
query: "app",
type: "all",
limit: 20,
})
expect(all.includes(path.join("src", "app", "index.ts"))).toBe(true)
expect(all.includes("src/app/")).toBe(true)
},
})
})
})