# workspace

Once you have a workspace you can find code, change one exact line, and run a command
without quoting anything for a shell.

The tree verbs are bound in every cell, so ordinary work starts with no import line:

```python
print(await search("TODO", "klbr-runtime/src", match="*.rs", max_results=20))
note = await open_file("docs/note.md")       # a handle, not a read
print(note.relative, (await note.text()).splitlines()[0])
await note.replace("old sentence", "new sentence")
print((await run(("cargo", "check", "-p", "klbr-runtime"), timeout=120)).exit_code)
```

They act on the source tree this session is already working in. `await workspace.current()` returns
that tree as an object when a cell wants to hold one, and a different tree is a different target
(`remote`), not a second argument. A file is the same idea one level down: open it once and work
through the handle.

## the shapes

`ls(path)` is one directory, the way a shell's `ls` is one level, and a file lists itself rather
than erroring. `ls(path, recursive=True, match=glob)` is the recursive one. Either way you get a `Listing`,
which is iterable:

```python
Listing(root, path, entries=(Entry(path, relative, is_dir, size), ...), pruned, truncated)
```

`Entry.size` is `None` for a directory, because a directory's size is not one number.

```python
sizes = sorted((e for e in await ls("klbr-runtime/src", recursive=True, match="*.rs")),
               key=lambda e: e.size)
print(len(sizes), sizes[-1].relative)
```

`search(needle, path, match=glob)` is literal, so you can paste text you just read instead of
escaping it as a regex, and it returns a `SearchResult`:

```python
SearchResult(matches=(TextMatch(path, relative, line, column, text), ...),
             truncated, files_searched, files_unreadable, pruned)
```

One file, opened once. `open_file(path)` gives a handle, not a read: it resolves the path and
nothing else, so a handle for a file that does not exist yet is a real handle - `write` creates it -
and every call through it sees the file as it is at that moment. `exists()` is where you ask the
question opening deliberately does not answer.

```python
page = await open_file("docs/note.md")   # File: .relative. Nothing read yet.
text = await page.text()                 # decoded, UTF-8 by default; raises on binary
raw = await page.read()                  # bytes, exactly as stored
await page.write(text + "\nmore\n")     # the whole file, str or bytes; creates parents
await page.replace("old sentence", "new sentence")   # one occurrence, or raises
await delete("docs/scratch.md", force=True)           # a file, or an empty directory; gone is fine
await delete("build/", recursive=True)               # a directory and everything in it
```

There are two ways to change part of a file and they are different tools. `replace` takes literal
text and refuses zero or many matches, so an ambiguous replacement is not applied somewhere you did
not look - both arguments are required, because a caller that left `new` out means something and
guessing which is how text disappears. `edit` takes an anchor from `hashlines()` and names the line
instead of reproducing it, so nothing you type has to match character for character:

```python
await page.hashlines()                        # 1:a3|# title
                                              # 2:f1|some text
await page.edit("2:f1", "other text")         # replace line 2
await page.edit("1:a3..2:f1", "# new title")  # replace a range
await page.edit("1:a3", "> note", after=True) # insert after line 1
await page.edit("2:f1")                       # delete that line
```

Both ends of a range are verified against the file as it is now, so an edit aimed at a file that
moved under you is refused rather than landing on whatever that line number means today. In `edit`
one argument is optional and it has one meaning: no `new` deletes. Prefer the anchor form once you
have the lines on screen: it does not spend tokens restating them, and a hash that no longer
matches fails loudly instead of silently.

## the trail

`workspace.current()` asks the host where this session's source tree is. It is not your
process's current directory, and it refuses to guess: a cell that silently edits the wrong
checkout is worse than a cell that errors. The verbs above are bound in the cell; the same
functions live in `klbr.workspace`, and `await klbr.workspace.current()` is the object form.

Build and version-control directories are not walked. `pruned` names the ones skipped, so "not
found" is a claim about where the walk went rather than about the tree - `include_pruned=True`
walks them anyway. `truncated` is the same kind of honesty for a cap: a hit list that stopped
early is a different answer from a short one.

`run` is not a shell and not a subprocess you started. The host owns the source root, the process
group, the deadline and the output bound, and it answers with a `RunResult` whose `completed` is
true even when `exit_code` is not zero: finishing and succeeding are separate facts. A build
started around that path is invisible to the host and survives the cell that started it.
