Skip to content

File Service & File Tree UI

dazeb edited this page Sep 17, 2026 · 2 revisions

File Service & File Tree UI

Purpose

This page covers two related layers:

  • src/core/file-service.ts — an Electron-free, guard-oriented set of file operations used by editor nodes and panels. Its API never throws; every call returns either a success shape or an error object.
  • src/renderer/src/components/FileTree.tsx — the VS Code-style sidebar that browses the active project, opens editor nodes, embeds source control, and lazily renders directory listings through window.termsprawl.files.list(root, rel, remote).

The core service is deliberately reusable by the Server Edition: it imports only node:fs and node:path, not Electron.

Core File Service Responsibilities

file-service.ts defines the shared result vocabulary for file access:

  • FileKind = 'text' | 'markdown' | 'image' | 'binary'
  • FileErrorCode = 'MISSING' | 'IO' | 'UNSUPPORTED'
  • FileReadResult
  • FileWriteResult
  • DirEntry with name, path, and kind: 'dir' | 'file'
  • DirListResult, whose error code additionally includes OUTSIDE

The main operations are:

Function Responsibility
classifyFile(filePath) Classify by lowercased extension. Markdown extensions become markdown, image extensions become image, known archive/binary/media/font extensions become binary, and unknown extensions default to text.
readProjectFile(filePath) Read text/markdown as UTF-8, report image paths without loading bytes, refuse known binary files with UNSUPPORTED, map ENOENT to MISSING, and map other failures to IO.
writeProjectFile(filePath, content) Refuse directory paths, create parent directories recursively, write UTF-8 content, and return { ok: true } or an IO error.
listProjectDir(root, rel = '.') List one folder under a project root. It resolves the requested relative path, guards it with resolveInside, filters hidden and skipped entries, stats each entry, and sorts directories before files.

The list guard is the key containment boundary:

flowchart TD
  FT[FileTree / TreeBranch] --> API[window.termsprawl.files.list]
  API --> L[listProjectDir root, rel]
  L --> G{resolveInside root, rel}
  G -->|outside| OUT[DirListResult error OUTSIDE]
  G -->|inside| S[statSync target]
  S -->|not folder| IO[DirListResult error IO]
  S -->|folder| RD[readdirSync target]
  RD --> F[skip dot entries, .git, node_modules]
  F --> ST[statSync each entry]
  ST --> SORT[directories first, then name localeCompare]
  SORT --> ENT[DirEntry list]
  ENT --> API
  API --> FT
  FT -->|file click| OPEN[onOpenFile path]
  FT -->|dir click| EXP[local expanded Set]
Loading

Key nodes in this flow:

  • resolveInside computes resolve(root) and resolve(rootAbs, rel). It rejects anything that is neither the root itself nor prefixed by rootAbs + sep.
  • listProjectDir therefore only lists one folder inside the project root. It does not recursively walk directories itself.
  • skipEntry hides every name starting with ., plus node_modules and .git.
  • Directory rows are sorted before file rows; within each group, names use localeCompare.
  • The renderer receives only DirEntry[] or an error result. It never sees raw fs objects.

File Tree UI Responsibilities

FileTree.tsx renders the project sidebar. It is not just a file tree: it is a VS Code-style activity rail with three sections:

  • files — open editor tabs plus the lazy directory tree.
  • source — embeds SourceControlPanel.
  • plugins — placeholder text pointing users to Settings → Plugins.

The component props are:

  • cwd?: string
  • remote?: ProjectRemote
  • onOpenFile: (path: string) => void
  • openEditors?: OpenEditorTab[]

For remote projects, the tree root is remote.path; otherwise it is cwd. The displayed root label uses remoteLabel(remote) for remote projects, or the basename of the local path.

Sidebar chrome and interaction state

FileTree uses a reducer from ../state/edge-reveal:

  • initialFileTreeChrome
  • applyFileTreeChrome
  • SidebarSection
  • TreeSide

The reducer state tracks side, open, pinned, and section. Local refs and timers handle hover behavior:

  • PANEL_WIDTH = 264
  • CLOSE_MS = 220
  • closeTimer delays close after mouse leave.
  • ignoreLeave temporarily suppresses close while flipping sides.
  • cancelClose clears the close timer.
  • scheduleClose does nothing when pinned or when ignoreLeave is set.
  • flipSide sets ignoreLeave, cancels close, dispatches flipSide, and clears the ignore flag after 400 ms.
  • reveal cancels close and dispatches reveal for the left or right side.

When the panel is closed, two hotspot elements render on the left and right canvas edges. Hovering either calls reveal(side). The <aside> itself cancels close on mouse enter and schedules close on mouse leave.

External section switching is handled through useSidebarRequests. When a request exists, the component dispatches switchSection, then consumes the request.

Rendering sections

In the files section:

  • A tabs label and list show openEditors.
  • If no editor nodes are open, it renders no open tabs.
  • Each open tab calls onOpenFile(tab.path) and displays the basename.
  • A files label is followed by either this project has no folder or a recursive TreeBranch.

In the source section:

  • If a root exists, it renders <SourceControlPanel cwd={root} remote={remote} embedded />.
  • Otherwise it shows the same no-folder message.

In the plugins section:

  • It renders a placeholder saying sidebar extensions are not available yet and directs users to Settings → Plugins.

TreeBranch Call Chain and State

TreeBranch is the recursive listing component. It receives:

  • root
  • rel
  • depth
  • onOpenFile
  • remote

Its local state is:

  • entries: DirEntry[] | null
  • error: string | null
  • expanded: Set<string>

The effect depends on root, rel, and remote. It calls:

window.termsprawl.files.list(root, rel, remote)

The effect uses a cancelled flag so late responses do not update unmounted branches. On error, it sets the error message and an empty entry list. On success, it clears the error and stores result.entries.

Rendering behavior:

  • If error is set, render the error message.
  • If entries is null, render loading….
  • If entries are empty, render empty.
  • Otherwise render a <ul className="file-tree-list">.

Each row is a button:

  • Directory click toggles the path in expanded.
  • File click calls onOpenFile(entry.path).
  • Directory rows show ▾ or ▸; file rows show ·.
  • Padding is computed from depth: 8 + depth * 12.

When an expanded directory renders, it recursively creates another TreeBranch with:

  • same root
  • rel={relFrom(root, entry.path)}
  • depth + 1
  • same onOpenFile
  • same remote

relFrom converts an absolute entry path back into a root-relative path by stripping the normalized root prefix. basenameOf is used for open editor tab labels.

Boundary Conditions and Guarantees

The tests in file-service.test.ts pin down several important contracts:

  • Markdown, image, text, and known binary extensions classify correctly, including uppercase image extensions.
  • Text and markdown files read as UTF-8 with the correct kind.
  • Image reads return { kind: 'image' } and do not load bytes.
  • Known binary extensions return UNSUPPORTED.
  • Missing files return MISSING.
  • Writes create parent directories recursively.
  • Writing to an existing directory path returns IO.
  • Directory listings put directories first and then sort alphabetically.
  • node_modules, .git, and dot entries are skipped.
  • Nested listings are relative to the project root.
  • ../outside is rejected with OUTSIDE.
  • Missing folders return MISSING.

One important scope boundary: readProjectFile and writeProjectFile accept a path directly. The root containment guard is implemented in listProjectDir through resolveInside. Callers that expose read/write to nodes or panels are responsible for supplying the intended project-scoped path.

Remote browsing is represented in the UI by passing remote through to window.termsprawl.files.list, and by using remote.path as the tree root. The comment in TreeBranch states that remote directory listing runs over SSH; the actual SSH-backed implementation is outside this page’s evidence.

Extension Points

Likely extension points in this module pair:

  • Add file kinds or adjust classification by extending IMAGE_EXT, MARKDOWN_EXT, BINARY_EXT, or classifyFile.
  • Add new sidebar sections by extending SidebarSection in the edge-reveal state module and adding a tab/render branch in FileTree.
  • Change tree row behavior in TreeBranch, especially how directories toggle and how files call onOpenFile.
  • Replace or extend the bridge method window.termsprawl.files.list for local versus remote listing.
  • Reuse readProjectFile / writeProjectFile from editor nodes or panels without pulling in Electron or renderer code.
  • Add tests in file-service.test.ts to lock down new classification, guard, or IO behavior.

Sources:

termsprawl

App Shell & Platform Foundations

Canvas, Nodes & Renderer State

Terminals & Session Continuity

Persistence, Projects & Files

Agent Runtime & Tooling

Chat Nodes & Model Providers

Git & Source Control

Embedded Browser Nodes

Server Edition

Relay & Remote Access

Integrations & Secondary Surfaces

Settings, Updates & Maintenance

Clone this wiki locally