btv.fs
btv.fs.stat(path)
btv.fs.stat(path) -> promise of { type, size, mtime, atime, mode } (follows links)
Defined in fs.lua.
btv.fs.lstat(path)
btv.fs.lstat(path) -> like stat but does NOT follow a symlink (type may be "link").
Defined in fs.lua.
btv.fs.exists(path)
btv.fs.exists(path) -> promise of a boolean. The one op that never rejects: a
missing path (or any error) resolves false. Existence of the entry itself, so
a dangling symlink is true. (The exists job resolves a bool rather than
rejecting, so no reject-to-false mapping is needed in the wrapper.)
Defined in fs.lua.
btv.fs.readdir(path)
btv.fs.readdir(path) -> promise of { { name=, type="file"|"directory"|"link" }, … },
the entries directly under path (no "."/".."), each with its dirent kind in the
SAME call (no per-entry stat). type is lstat-flavoured (a symlink reports "link").
Defined in fs.lua.
btv.fs.gitignore(text)
btv.fs.gitignore(text) -> a matcher for one ignore file’s contents (a .gitignore,
.ignore, .rgignore or .git/info/exclude), plus the list of messages for lines
it had to skip (an invalid glob — git and rg skip those too). The semantics are
ripgrep’s own (the ignore crate), so negation, anchoring, directory-only patterns
and ** decide exactly as rg does.
matcher:match(path, is_dir) -> "ignore", "whitelist" (a !negated line) or
nil (no line matches). path is relative to the ignore file’s own directory and
/-separated; is_dir matters for a build/-style pattern. The last matching line
decides, as in git.
local m = btv.fs.gitignore("*.log\n!keep.log\nbuild/\n")
m:match("debug.log") --> "ignore"
m:match("keep.log") --> "whitelist"
m:match("build", true) --> "ignore"
m:match("src/main.rs") --> nil
Defined in fs.lua.
btv.fs.walk(dir, opts)
btv.fs.walk(dir[, opts]) -> promise of a LIST of file paths relative to dir, a
recursive directory listing built from readdir. The transport-agnostic file
enumeration the codebase reaches for when rg/fd aren’t available (the pure web
client, where a spawn has no real shell) — it rides the same off-tick fs seam as
every other btv.fs op, so it works against local disk, a daemon, and OPFS alike.
The relative paths are spelled with the session’s separator (btv.utils.path_join),
as rg / fd print them — so a file reads the same whichever enumerator found it.
opts:
max cap on files returned (default 50000) — a runaway guard on huge trees
hidden include dotfiles / dotdirs (default false)
skip set of directory basenames to prune (default { [".git"] = true })
gitignore honour ignore files the way `rg` does (default false)
With gitignore = true the walk reads each directory’s .gitignore, .ignore and
.rgignore as it descends (and those of the directories above dir), plus the
repository’s info/exclude at a repository root, and prunes what they
ignore — negation, anchoring and precedence as in rg, through btv.fs.gitignore.
As in rg, a .gitignore counts only inside a git repository (a directory with a
.git at or above it) and not above that repository’s root, while .ignore /
.rgignore count anywhere. A nested repository (a submodule) starts afresh: the
enclosing repository’s git rules stop at its root. The global git excludes file
(core.excludesFile) is not read. Directories with no ignore file cost nothing extra.
An unreadable subdirectory is skipped (not fatal); an ignore file that exists but
cannot be read, or is not a regular file, is skipped with a warning naming it (once
a session), as rg warns and walks on. MUST be awaited inside btv.async.
Defined in fs.lua.
btv.fs.grep(dir, query, opts)
btv.fs.grep(dir, query[, opts]) -> promise of a LIST of matches, each
{ path = <rel>, row = <1-based lnum>, col = <1-based>, text = <line> }: a recursive,
transport-agnostic plain-substring search. The fallback the grep picker reaches for
when rg/grep aren’t available (the pure web client) — it rides the same off-tick
fs seam as every other btv.fs op, so it works against local disk, a daemon, and OPFS.
Walks dir (btv.fs.walk), reads each file (btv.fs.read_text), and matches query as a
LITERAL substring per line. Binary / unreadable files (read_text rejects) are skipped.
opts pass through to btv.fs.walk (max / hidden / skip / gitignore). MUST be awaited
inside btv.async.
Defined in fs.lua.
btv.fs.read(path)
btv.fs.read(path) -> promise of the file’s RAW bytes (a Lua byte-string).
Defined in fs.lua.
btv.fs.read_text(path, opts)
btv.fs.read_text(path[, { encoding = "utf-8" }]) -> promise of decoded text.
Decodes through the encoding seam and REJECTS (EILSEQ) on invalid input — never
lossy replacement text. Use btv.fs.read for raw bytes.
Defined in fs.lua.
btv.fs.write(path, data)
btv.fs.write(path, data) -> promise (resolves nil). Truncates / creates.
Defined in fs.lua.
btv.fs.append(path, data)
btv.fs.append(path, data) -> promise (resolves nil). Creates if absent.
Defined in fs.lua.
btv.fs.mkdir(path, opts)
btv.fs.mkdir(path[, { recursive = false, mode = 0o755 }]) -> promise (resolves
nil). mode is the Unix permission bits applied to every directory created
(defaults to 0o755 in Rust when omitted; ignored off Unix) — pass it to keep a
private data/state dir from being created world-readable.
Defined in fs.lua.
btv.fs.rename(from, to)
btv.fs.rename(from, to) -> promise (resolves nil).
Defined in fs.lua.
btv.fs.remove(path, opts)
btv.fs.remove(path[, { recursive = false }]) -> promise (resolves nil). A file is
unlinked; a directory needs recursive unless already empty.
Defined in fs.lua.
btv.fs.copy(src, dst, opts)
btv.fs.copy(src, dst[, { recursive = false }]) -> promise (resolves nil). A file
copies (overwriting); a directory needs recursive.
Defined in fs.lua.
btv.fs.realpath(path)
btv.fs.realpath(path) -> promise of the canonical absolute path (symlinks resolved).
Defined in fs.lua.
btv.fs.which(name)
btv.fs.which(name) -> promise of the absolute path of the executable name, or
nil when nothing matches. A bare name is searched across $PATH; a name that
already contains a / is taken as an explicit path and accepted only when it is
an executable file. The async, transport-agnostic replacement for vim’s blocking
executable() / exepath() — a build/tool lookup is I/O, so it rides the same
off-tick btv.fs seam as every other op and works unchanged against local disk, a
daemon, and a browser session.
It resolves nil rather than rejecting when the program is absent: “not installed”
is a true answer, and only a transport failure is an error. That makes the two
common shapes read naturally —
-- prefer a project-local binary, fall back to the one on $PATH
local local_bin = btv.utils.joinpath(root, "node_modules/.bin", "eslint")
local cmd = btv.await(btv.fs.which(local_bin)) or "eslint"
-- gate a feature on a tool being present
btv.fs.which("rg"):next(function(path)
if path then use_ripgrep(path) end
end)
In a serverless browser session there are no executables at all, so every
lookup resolves nil; with a daemon attached the daemon’s own $PATH answers — which
is the right one, since that is where the language servers and tools actually run.
Defined in fs.lua.
watch:next()
:next() -> promise of the next change batch, nil at end (after :stop()), or a
rejection carrying the watch error. SEQUENTIAL like the process Stream: one
outstanding :next() at a time (what a for loop does); batches arriving between
pulls buffer in _queue.
Defined in fs.lua.
watch:stop()
:stop() cancels the native watch and ends iteration (a parked :next resolves nil).
Defined in fs.lua.
btv.fs.watch(path, opts)
btv.fs.watch(path[, { recursive = false }]) -> a Watch you iterate with
btv.await_each (the streaming sibling of the one-shot ops, same shape as
btv.run_stream). Each step yields a COALESCED change batch:
{ kind = “create”|“modify”|“remove”|“rename”, paths = { <abspath>, … } }
A consumer that stops pulling while changes keep arriving buffers at most 1024
batches; the next change after that is replaced by one
{ kind = "overflow", paths = {} } batch (later ones are dropped until the
consumer catches up), which says “changes were missed, rescan”.
bursts within a 10 ms window are merged in the server, one batch per kind
(paths deduped within each). A burst that mixes kinds therefore yields one batch
each rather than a single coarsened one: writing a new file is a "create" plus a
"modify", and folding them together would lose the only signal that says the file
is new. Want a longer settle? Wrap btv.utils.debounce on top.
btv.async(function() local w = btv.fs.watch(dir, { recursive = true }) for ev in btv.await_each(w) do redraw(ev.paths) end – until w:stop() end)()
The watch is armed where the session’s files are: locally in a local session, and on the daemon in a daemon session (native or browser) — so a remote project reports its own changes rather than this machine’s. A native daemon link re-arms its watches after a reconnect, so a live iterator survives an outage.
A watch that can’t arm (bad path, watch limit) or a session with no change source at
all (serverless browser) REJECTS the first pull — fail loud, never a dead watch.
:stop() cancels the watch and ends the iteration.
Defined in fs.lua.