import { Bus } from "@/bus"import { BusEvent } from "@/bus/bus-event"import z from "zod"import { $ } from "bun"import type { BunFile } from "bun"import { formatPatch, structuredPatch } from "diff"import { HTTPException } from "hono/http-exception"import path from "path"import fs from "fs"import ignore from "ignore"import { Log } from "../util/log"import { Filesystem } from "../util/filesystem"import { Instance } from "../project/instance"import { Ripgrep } from "./ripgrep"import fuzzysort from "fuzzysort"import { Global } from "../global"import { FileWatcher } from "./watcher" export namespace File { const log = Log.create({ service: "file" }) export const Info = z .object({ path: z.string(), added: z.number().int(), removed: z.number().int(), status: z.enum(["added", "deleted", "modified"]), }) .meta({ ref: "File", }) export type Info = z.infer<typeof Info> export const Node = z .object({ name: z.string(), path: z.string(), absolute: z.string(), type: z.enum(["file", "directory"]), ignored: z.boolean(), size: z.number().optional(), mtime: z.number().optional(), }) .meta({ ref: "FileNode", }) export type Node = z.infer<typeof Node> export const Content = z .object({ type: z.literal("text"), content: z.string(), before: z.string().optional(), diff: z.string().optional(), patch: z .object({ oldFileName: z.string(), newFileName: z.string(), oldHeader: z.string().optional(), newHeader: z.string().optional(), hunks: z.array( z.object({ oldStart: z.number(), oldLines: z.number(), newStart: z.number(), newLines: z.number(), lines: z.array(z.string()), }), ), index: z.string().optional(), }) .optional(), encoding: z.literal("base64").optional(), mimeType: z.string().optional(), }) .meta({ ref: "FileContent", }) export type Content = z.infer<typeof Content> async function shouldEncode(file: BunFile): Promise<boolean> { const type = file.type?.toLowerCase() log.info("shouldEncode", { type }) if (!type) return false if (type.startsWith("text/")) return false if (type.includes("charset=")) return false const parts = type.split("/", 2) const top = parts[0] const rest = parts[1] ?? "" const sub = rest.split(";", 1)[0] const tops = ["image", "audio", "video", "font", "model", "multipart"] if (tops.includes(top)) return true const bins = [ "zip", "gzip", "bzip", "compressed", "binary", "pdf", "msword", "powerpoint", "excel", "ogg", "exe", "dmg", "iso", "rar", ] if (bins.some((mark) => sub.includes(mark))) return true return false } export const Event = { Edited: BusEvent.define( "file.edited", z.object({ file: z.string(), }), ), } const state = Instance.state(async () => { type Entry = { files: string[]; dirs: string[] } let cache: Entry = { files: [], dirs: [] } let fetching = false const isGlobalHome = Instance.directory === Global.Path.home && Instance.project.id === "global" const fn = async (result: Entry) => { // Disable scanning if in root of file system if (Instance.directory === path.parse(Instance.directory).root) return fetching = true if (isGlobalHome) { const dirs = new Set<string>() const ignore = new Set<string>() if (process.platform === "darwin") ignore.add("Library") if (process.platform === "win32") ignore.add("AppData") const ignoreNested = new Set(["node_modules", "dist", "build", "target", "vendor"]) const shouldIgnore = (name: string) => name.startsWith(".") || ignore.has(name) const shouldIgnoreNested = (name: string) => name.startsWith(".") || ignoreNested.has(name) const top = await fs.promises .readdir(Instance.directory, { withFileTypes: true }) .catch(() => [] as fs.Dirent[]) for (const entry of top) { if (!entry.isDirectory()) continue if (shouldIgnore(entry.name)) continue dirs.add(entry.name + "/") const base = path.join(Instance.directory, entry.name) const children = await fs.promises.readdir(base, { withFileTypes: true }).catch(() => [] as fs.Dirent[]) for (const child of children) { if (!child.isDirectory()) continue if (shouldIgnoreNested(child.name)) continue dirs.add(entry.name + "/" + child.name + "/") } } result.dirs = Array.from(dirs).toSorted() cache = result fetching = false return } const set = new Set<string>() for await (const file of Ripgrep.files({ cwd: Instance.directory })) { result.files.push(file) let current = file while (true) { const dir = path.dirname(current) if (dir === ".") break if (dir === current) break current = dir if (set.has(dir)) continue set.add(dir) result.dirs.push(dir + "/") } } cache = result fetching = false } fn(cache) return { async files() { if (!fetching) { fn({ files: [], dirs: [], }) } return cache }, } }) export function init() { state() } export async function status() { const project = Instance.project if (project.vcs !== "git") return [] const diffOutput = await $`git -c core.quotepath=false diff --numstat HEAD` .cwd(Instance.directory) .quiet() .nothrow() .text() const changedFiles: Info[] = [] if (diffOutput.trim()) { const lines = diffOutput.trim().split("\n") for (const line of lines) { const [added, removed, filepath] = line.split("\t") changedFiles.push({ path: filepath, added: added === "-" ? 0 : parseInt(added, 10), removed: removed === "-" ? 0 : parseInt(removed, 10), status: "modified", }) } } const untrackedOutput = await $`git -c core.quotepath=false ls-files --others --exclude-standard` .cwd(Instance.directory) .quiet() .nothrow() .text() if (untrackedOutput.trim()) { const untrackedFiles = untrackedOutput.trim().split("\n") for (const filepath of untrackedFiles) { try { const content = await Bun.file(path.join(Instance.directory, filepath)).text() const lines = content.split("\n").length changedFiles.push({ path: filepath, added: lines, removed: 0, status: "added", }) } catch { continue } } } // Get deleted files const deletedOutput = await $`git -c core.quotepath=false diff --name-only --diff-filter=D HEAD` .cwd(Instance.directory) .quiet() .nothrow() .text() if (deletedOutput.trim()) { const deletedFiles = deletedOutput.trim().split("\n") for (const filepath of deletedFiles) { changedFiles.push({ path: filepath, added: 0, removed: 0, // Could get original line count but would require another git command status: "deleted", }) } } return changedFiles.map((x) => ({ ...x, path: path.relative(Instance.directory, x.path), })) } export async function read(file: string): Promise<Content> { using _ = log.time("read", { file }) const project = Instance.project const full = path.join(Instance.directory, file) // TODO: Filesystem.contains is lexical only - symlinks inside the project can escape. // TODO: On Windows, cross-drive paths bypass this check. Consider realpath canonicalization. if (!Instance.containsPath(full)) { throw new Error(`Access denied: path escapes project directory`) } const bunFile = Bun.file(full) if (!(await bunFile.exists())) { return { type: "text", content: "" } } const encode = await shouldEncode(bunFile) if (encode) { const buffer = await bunFile.arrayBuffer().catch(() => new ArrayBuffer(0)) const content = Buffer.from(buffer).toString("base64") const mimeType = bunFile.type || "application/octet-stream" return { type: "text", content, mimeType, encoding: "base64" } } // Return the file content verbatim — callers like the web editor write // it back, so trimming here would silently strip leading/trailing // whitespace and the trailing newline on the first save. const content = await bunFile.text().catch(() => "") if (project.vcs === "git") { let diff = await $`git diff ${file}`.cwd(Instance.directory).quiet().nothrow().text() if (!diff.trim()) diff = await $`git diff --staged ${file}`.cwd(Instance.directory).quiet().nothrow().text() if (diff.trim()) { const original = await $`git show HEAD:${file}`.cwd(Instance.directory).quiet().nothrow().text() const patch = structuredPatch(file, file, original, content, "old", "new", { context: Infinity, ignoreWhitespace: true, }) const diff = formatPatch(patch) return { type: "text", content, before: original, patch, diff } } } return { type: "text", content } } export async function write(file: string, content: string): Promise<Content> { using _ = log.time("write", { file }) const full = path.join(Instance.directory, file) if (!Instance.containsPath(full)) { throw new Error(`Access denied: path escapes project directory`) } const exists = await Bun.file(full).exists() await Bun.write(full, content) await Bus.publish(File.Event.Edited, { file: full, }) await Bus.publish(FileWatcher.Event.Updated, { file: full, event: exists ? "change" : "add", }) return read(file) } export async function list(dir?: string) { const exclude = [".git", ".DS_Store"] const project = Instance.project let ignored = (_: string) => false if (project.vcs === "git") { const ig = ignore() const gitignore = Bun.file(path.join(Instance.worktree, ".gitignore")) if (await gitignore.exists()) { ig.add(await gitignore.text()) } const ignoreFile = Bun.file(path.join(Instance.worktree, ".ignore")) if (await ignoreFile.exists()) { ig.add(await ignoreFile.text()) } ignored = ig.ignores.bind(ig) } const resolved = dir ? path.join(Instance.directory, dir) : Instance.directory // TODO: Filesystem.contains is lexical only - symlinks inside the project can escape. // TODO: On Windows, cross-drive paths bypass this check. Consider realpath canonicalization. if (!Instance.containsPath(resolved)) { throw new Error(`Access denied: path escapes project directory`) } const nodes: Node[] = [] const entries: fs.Dirent[] = await fs.promises .readdir(resolved, { withFileTypes: true }) .catch((err: NodeJS.ErrnoException) => { // Surface permission errors as 403 with a TCC-aware message so the // SPA can show "grant Full Disk Access" instead of "0 entries". // macOS blocks Desktop/Documents/Downloads listings for any process // that doesn't have FDA, and node returns EACCES/EPERM in that case. if (err?.code === "EACCES" || err?.code === "EPERM") { const macHint = process.platform === "darwin" ? " — grant Full Disk Access to the openscience binary in System Settings → Privacy & Security" : "" throw new HTTPException(403, { message: `permission denied reading ${resolved}${macHint}`, }) } if (err?.code === "ENOENT") { throw new HTTPException(404, { message: `path not found: ${resolved}` }) } // Unknown error: log and degrade to empty so the request still // completes — preserves the prior behaviour for benign cases. log.warn("file.list readdir failed", { resolved, error: String(err?.message ?? err) }) return [] as fs.Dirent[] }) for (const entry of entries) { if (exclude.includes(entry.name)) continue const fullPath = path.join(resolved, entry.name) const relativePath = path.relative(Instance.directory, fullPath) const type = entry.isDirectory() ? "directory" : "file" // Stat each entry for the file-explorer size / modified columns. Failures // (broken symlink, races) degrade to undefined rather than dropping the row. const stat = await fs.promises.stat(fullPath).catch(() => undefined) nodes.push({ name: entry.name, path: relativePath, absolute: fullPath, type, ignored: ignored(type === "directory" ? relativePath + "/" : relativePath), size: stat && type === "file" ? stat.size : undefined, mtime: stat ? Math.round(stat.mtimeMs) : undefined, }) } return nodes.sort((a, b) => { if (a.type !== b.type) { return a.type === "directory" ? -1 : 1 } return a.name.localeCompare(b.name) }) } export async function search(input: { query: string; limit?: number; dirs?: boolean; type?: "file" | "directory" }) { const query = input.query.trim() const limit = input.limit ?? 100 const kind = input.type ?? (input.dirs === false ? "file" : "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) } return [...visible, ...hiddenItems] } if (!query) { if (kind === "file") return result.files.slice(0, limit) return sortHiddenLast(result.dirs.toSorted()).slice(0, limit) } const items = kind === "file" ? result.files : kind === "directory" ? result.dirs : [...result.files, ...result.dirs] 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 log.info("search", { query, kind, results: output.length }) return output }}
import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event"
import z from "zod"
import { $ } from "bun"
import type { BunFile } from "bun"
import { formatPatch, structuredPatch } from "diff"
import { HTTPException } from "hono/http-exception"
import path from "path"
import fs from "fs"
import ignore from "ignore"
import { Log } from "../util/log"
import { Filesystem } from "../util/filesystem"
import { Instance } from "../project/instance"
import { Ripgrep } from "./ripgrep"
import fuzzysort from "fuzzysort"
import { Global } from "../global"
import { FileWatcher } from "./watcher"
export namespace File {
const log = Log.create({ service: "file" })
export const Info = z
.object({
path: z.string(),
added: z.number().int(),
removed: z.number().int(),
status: z.enum(["added", "deleted", "modified"]),
})
.meta({
ref: "File",
})
export type Info = z.infer<typeof Info>
export const Node = z
.object({
name: z.string(),
path: z.string(),
absolute: z.string(),
type: z.enum(["file", "directory"]),
ignored: z.boolean(),
size: z.number().optional(),
mtime: z.number().optional(),
})
.meta({
ref: "FileNode",
})
export type Node = z.infer<typeof Node>
export const Content = z
.object({
type: z.literal("text"),
content: z.string(),
before: z.string().optional(),
diff: z.string().optional(),
patch: z
.object({
oldFileName: z.string(),
newFileName: z.string(),
oldHeader: z.string().optional(),
newHeader: z.string().optional(),
hunks: z.array(
z.object({
oldStart: z.number(),
oldLines: z.number(),
newStart: z.number(),
newLines: z.number(),
lines: z.array(z.string()),
}),
),
index: z.string().optional(),
})
.optional(),
encoding: z.literal("base64").optional(),
mimeType: z.string().optional(),
})
.meta({
ref: "FileContent",
})
export type Content = z.infer<typeof Content>
async function shouldEncode(file: BunFile): Promise<boolean> {
const type = file.type?.toLowerCase()
log.info("shouldEncode", { type })
if (!type) return false
if (type.startsWith("text/")) return false
if (type.includes("charset=")) return false
const parts = type.split("/", 2)
const top = parts[0]
const rest = parts[1] ?? ""
const sub = rest.split(";", 1)[0]
const tops = ["image", "audio", "video", "font", "model", "multipart"]
if (tops.includes(top)) return true
const bins = [
"zip",
"gzip",
"bzip",
"compressed",
"binary",
"pdf",
"msword",
"powerpoint",
"excel",
"ogg",
"exe",
"dmg",
"iso",
"rar",
]
if (bins.some((mark) => sub.includes(mark))) return true
return false
}
export const Event = {
Edited: BusEvent.define(
"file.edited",
z.object({
file: z.string(),
}),
),
}
const state = Instance.state(async () => {
type Entry = { files: string[]; dirs: string[] }
let cache: Entry = { files: [], dirs: [] }
let fetching = false
const isGlobalHome = Instance.directory === Global.Path.home && Instance.project.id === "global"
const fn = async (result: Entry) => {
// Disable scanning if in root of file system
if (Instance.directory === path.parse(Instance.directory).root) return
fetching = true
if (isGlobalHome) {
const dirs = new Set<string>()
const ignore = new Set<string>()
if (process.platform === "darwin") ignore.add("Library")
if (process.platform === "win32") ignore.add("AppData")
const ignoreNested = new Set(["node_modules", "dist", "build", "target", "vendor"])
const shouldIgnore = (name: string) => name.startsWith(".") || ignore.has(name)
const shouldIgnoreNested = (name: string) => name.startsWith(".") || ignoreNested.has(name)
const top = await fs.promises
.readdir(Instance.directory, { withFileTypes: true })
.catch(() => [] as fs.Dirent[])
for (const entry of top) {
if (!entry.isDirectory()) continue
if (shouldIgnore(entry.name)) continue
dirs.add(entry.name + "/")
const base = path.join(Instance.directory, entry.name)
const children = await fs.promises.readdir(base, { withFileTypes: true }).catch(() => [] as fs.Dirent[])
for (const child of children) {
if (!child.isDirectory()) continue
if (shouldIgnoreNested(child.name)) continue
dirs.add(entry.name + "/" + child.name + "/")
}
}
result.dirs = Array.from(dirs).toSorted()
cache = result
fetching = false
return
}
const set = new Set<string>()
for await (const file of Ripgrep.files({ cwd: Instance.directory })) {
result.files.push(file)
let current = file
while (true) {
const dir = path.dirname(current)
if (dir === ".") break
if (dir === current) break
current = dir
if (set.has(dir)) continue
set.add(dir)
result.dirs.push(dir + "/")
}
}
cache = result
fetching = false
}
fn(cache)
return {
async files() {
if (!fetching) {
fn({
files: [],
dirs: [],
})
}
return cache
},
}
})
export function init() {
state()
}
export async function status() {
const project = Instance.project
if (project.vcs !== "git") return []
const diffOutput = await $`git -c core.quotepath=false diff --numstat HEAD`
.cwd(Instance.directory)
.quiet()
.nothrow()
.text()
const changedFiles: Info[] = []
if (diffOutput.trim()) {
const lines = diffOutput.trim().split("\n")
for (const line of lines) {
const [added, removed, filepath] = line.split("\t")
changedFiles.push({
path: filepath,
added: added === "-" ? 0 : parseInt(added, 10),
removed: removed === "-" ? 0 : parseInt(removed, 10),
status: "modified",
})
}
}
const untrackedOutput = await $`git -c core.quotepath=false ls-files --others --exclude-standard`
.cwd(Instance.directory)
.quiet()
.nothrow()
.text()
if (untrackedOutput.trim()) {
const untrackedFiles = untrackedOutput.trim().split("\n")
for (const filepath of untrackedFiles) {
try {
const content = await Bun.file(path.join(Instance.directory, filepath)).text()
const lines = content.split("\n").length
changedFiles.push({
path: filepath,
added: lines,
removed: 0,
status: "added",
})
} catch {
continue
}
}
}
// Get deleted files
const deletedOutput = await $`git -c core.quotepath=false diff --name-only --diff-filter=D HEAD`
.cwd(Instance.directory)
.quiet()
.nothrow()
.text()
if (deletedOutput.trim()) {
const deletedFiles = deletedOutput.trim().split("\n")
for (const filepath of deletedFiles) {
changedFiles.push({
path: filepath,
added: 0,
removed: 0, // Could get original line count but would require another git command
status: "deleted",
})
}
}
return changedFiles.map((x) => ({
...x,
path: path.relative(Instance.directory, x.path),
}))
}
export async function read(file: string): Promise<Content> {
using _ = log.time("read", { file })
const project = Instance.project
const full = path.join(Instance.directory, file)
// TODO: Filesystem.contains is lexical only - symlinks inside the project can escape.
// TODO: On Windows, cross-drive paths bypass this check. Consider realpath canonicalization.
if (!Instance.containsPath(full)) {
throw new Error(`Access denied: path escapes project directory`)
}
const bunFile = Bun.file(full)
if (!(await bunFile.exists())) {
return { type: "text", content: "" }
}
const encode = await shouldEncode(bunFile)
if (encode) {
const buffer = await bunFile.arrayBuffer().catch(() => new ArrayBuffer(0))
const content = Buffer.from(buffer).toString("base64")
const mimeType = bunFile.type || "application/octet-stream"
return { type: "text", content, mimeType, encoding: "base64" }
}
// Return the file content verbatim — callers like the web editor write
// it back, so trimming here would silently strip leading/trailing
// whitespace and the trailing newline on the first save.
const content = await bunFile.text().catch(() => "")
if (project.vcs === "git") {
let diff = await $`git diff ${file}`.cwd(Instance.directory).quiet().nothrow().text()
if (!diff.trim()) diff = await $`git diff --staged ${file}`.cwd(Instance.directory).quiet().nothrow().text()
if (diff.trim()) {
const original = await $`git show HEAD:${file}`.cwd(Instance.directory).quiet().nothrow().text()
const patch = structuredPatch(file, file, original, content, "old", "new", {
context: Infinity,
ignoreWhitespace: true,
})
const diff = formatPatch(patch)
return { type: "text", content, before: original, patch, diff }
}
}
return { type: "text", content }
}
export async function write(file: string, content: string): Promise<Content> {
using _ = log.time("write", { file })
const full = path.join(Instance.directory, file)
if (!Instance.containsPath(full)) {
throw new Error(`Access denied: path escapes project directory`)
}
const exists = await Bun.file(full).exists()
await Bun.write(full, content)
await Bus.publish(File.Event.Edited, {
file: full,
})
await Bus.publish(FileWatcher.Event.Updated, {
file: full,
event: exists ? "change" : "add",
})
return read(file)
}
export async function list(dir?: string) {
const exclude = [".git", ".DS_Store"]
const project = Instance.project
let ignored = (_: string) => false
if (project.vcs === "git") {
const ig = ignore()
const gitignore = Bun.file(path.join(Instance.worktree, ".gitignore"))
if (await gitignore.exists()) {
ig.add(await gitignore.text())
}
const ignoreFile = Bun.file(path.join(Instance.worktree, ".ignore"))
if (await ignoreFile.exists()) {
ig.add(await ignoreFile.text())
}
ignored = ig.ignores.bind(ig)
}
const resolved = dir ? path.join(Instance.directory, dir) : Instance.directory
// TODO: Filesystem.contains is lexical only - symlinks inside the project can escape.
// TODO: On Windows, cross-drive paths bypass this check. Consider realpath canonicalization.
if (!Instance.containsPath(resolved)) {
throw new Error(`Access denied: path escapes project directory`)
}
const nodes: Node[] = []
const entries: fs.Dirent[] = await fs.promises
.readdir(resolved, { withFileTypes: true })
.catch((err: NodeJS.ErrnoException) => {
// Surface permission errors as 403 with a TCC-aware message so the
// SPA can show "grant Full Disk Access" instead of "0 entries".
// macOS blocks Desktop/Documents/Downloads listings for any process
// that doesn't have FDA, and node returns EACCES/EPERM in that case.
if (err?.code === "EACCES" || err?.code === "EPERM") {
const macHint =
process.platform === "darwin"
? " — grant Full Disk Access to the openscience binary in System Settings → Privacy & Security"
: ""
throw new HTTPException(403, {
message: `permission denied reading ${resolved}${macHint}`,
})
}
if (err?.code === "ENOENT") {
throw new HTTPException(404, { message: `path not found: ${resolved}` })
}
// Unknown error: log and degrade to empty so the request still
// completes — preserves the prior behaviour for benign cases.
log.warn("file.list readdir failed", { resolved, error: String(err?.message ?? err) })
return [] as fs.Dirent[]
})
for (const entry of entries) {
if (exclude.includes(entry.name)) continue
const fullPath = path.join(resolved, entry.name)
const relativePath = path.relative(Instance.directory, fullPath)
const type = entry.isDirectory() ? "directory" : "file"
// Stat each entry for the file-explorer size / modified columns. Failures
// (broken symlink, races) degrade to undefined rather than dropping the row.
const stat = await fs.promises.stat(fullPath).catch(() => undefined)
nodes.push({
name: entry.name,
path: relativePath,
absolute: fullPath,
type,
ignored: ignored(type === "directory" ? relativePath + "/" : relativePath),
size: stat && type === "file" ? stat.size : undefined,
mtime: stat ? Math.round(stat.mtimeMs) : undefined,
})
}
return nodes.sort((a, b) => {
if (a.type !== b.type) {
return a.type === "directory" ? -1 : 1
}
return a.name.localeCompare(b.name)
})
}
export async function search(input: { query: string; limit?: number; dirs?: boolean; type?: "file" | "directory" }) {
const query = input.query.trim()
const limit = input.limit ?? 100
const kind = input.type ?? (input.dirs === false ? "file" : "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)
}
return [...visible, ...hiddenItems]
}
if (!query) {
if (kind === "file") return result.files.slice(0, limit)
return sortHiddenLast(result.dirs.toSorted()).slice(0, limit)
}
const items =
kind === "file" ? result.files : kind === "directory" ? result.dirs : [...result.files, ...result.dirs]
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
log.info("search", { query, kind, results: output.length })
return output
}
}