Skip to content

Latest commit

 

History

History
200 lines (147 loc) · 25.5 KB

ARCHITECTURE.md

File metadata and controls

200 lines (147 loc) · 25.5 KB

forge Architecture

Process entry and headless commands

src/bin/forge.rs is intentionally tiny and delegates to the library application. src/cli.rs handles help, version, human/JSON config validation and diagnostics, config initialization/backup recovery, shell-integration output, and launch overrides before GTK initialization. Headless operations therefore remain usable over SSH and in CI. A normal launch enters src/main.rs, which builds the libadwaita application and the shared UiState.

Font resolution

src/font.rs checks the display's Pango family catalog after GTK initialization. Only an unavailable, standalone default JetBrainsMono Nerd Font Mono family is replaced with Monospace at rendering time, before icon fallback is appended. Generated Pango family-list separators contain no added whitespace: set_family treats that whitespace as part of the fallback name. CSS quoting/separators remain a separate path. The availability result is cached for the process lifetime; restart after installing a new font. The Pango description retains its size, weight and style, and config is never rewritten. Custom family names and lists keep their existing behavior.

Font zoom has two independently persisted effective scales: content font_scale and top-bar/sidebar chrome_font_scale. UI base size is stored independently as chrome_font_size, a size-only Pango description retaining fractional points or absolute pixels. Both UI values inherit legacy values once during config loading. Capture-phase scroll controllers attach to disjoint region roots (top handle, left sidebar, right AI/Tasks stack, center notebook/search column, bottom bar), so pointer ancestry selects the group and consumes Ctrl+wheel before Block/VTE handlers. Window-level overlays and separate dialogs have no underlying-region zoom handler. Native popup routing and GTK visual behavior require a display-backed acceptance pass; headless routing guards do not prove them. Surface deltas normalize through the pinned shared wheel constant, preserving fractional steps. Separate idle slots coalesce rendering; the existing config debounce persists both values in one atomic snapshot.

Terminal models

forge has two explicit terminal models:

  • VTE mode attaches a GTK VTE widget directly to a PTY and supports conventional split panes.
  • Block mode, the default, owns its PTY and reader lifecycle in TermView, parses shell integration markers, and renders commands and output as searchable finished blocks plus one live terminal.

PaneLeaf is the backend-neutral ownership boundary. It exposes the visible root, live input surface, PTY/shell process probe, cwd/restorable command, focus, and teardown without discovering an arbitrary nested VTE widget. A split inherits the focused leaf's backend: Block creates a Block sibling and VTE creates a VTE sibling; both can be nested in the same pane tree. Close paths scan every leaf, restore zoomed trees before mutation, confirm foreground work, and terminate the corresponding process group.

UI composition

UiState coordinates tabs, panes, sidebars, search, configuration reloads, and actions. The file sidebar uses GTK4's supported model-view stack: gio::ListStore, TreeListModel, ListView, and TreeExpander, with a MultiSelection driving batch operations (ctrl+click/shift+click come from GTK; right-click on a selected row applies Delete/Copy/Cut/Copy Path to the whole selection, Delete confirms with a count and up to five names, and batch paste/delete continue past per-item failures into a summary toast; a cut deletes only successfully transferred sources). A header toggle opens a type-to-filter row: the tree wraps in a FilterListModel whose predicate consults a visible-path set (case-insensitive name matches plus their ancestors) computed from the loaded child stores only — filtering triggers no scans, auto-expands materialized ancestors of matches, and restores prior expansion on clear. Directory scans run on named worker threads; generation checks prevent stale results from repopulating a changed root. Listing and file operations dispatch through ui::remote_fs, so the same tree browses the local disk or any configured ssh/docker remote host: a fixed POSIX sh probe script (protocol v3) runs on the far side through the system ssh/docker binaries (script as one sh -c argv element, single-quote-escaped positional parameters, bounded output, hard timeouts) and its NUL-separated listing is parsed back. A header selector switches location; remote start-directory discovery uses the bounded file-op worker rather than GTK's thread, and a terminal button opens a local tab at the visible root or the selected managed SSH/Docker profile. Because FsLocation::Remote is index-backed, every settings/reload update reconciles it and the file clipboard by exact full-profile identity: a unique reorder remaps, while removal/replacement/ambiguity clears the old remote state and returns Local. Dropdown model rebuilds suppress their intermediate selection notifications, and every context menu/name/delete intent carries its originating generation plus location, so a dialog left open across a root/target switch cannot apply its old absolute path to the new backend. Copy/Cut also receives a monotonic intent id: a delayed Paste menu resolves that id through the live, already-reconciled clipboard before starting, and a slow cut completion can retire only the same intent, including across a safe profile reorder. Managed-tab OSC 7 following first distinguishes the focused local or remote split leaf, then resolves one validated current profile by full identity (only a restored tab has an explicit per-tab session override), never by display name. A right-click menu offers New File/Folder, Rename, Delete, Copy, Cut, Copy Path, Paste and Refresh on both backends. Paste across locations streams between filesystems without buffering whole payloads (512 MiB cap): cat/put for files, tar/untar for directories, and a local-temp relay between two remote hosts; a cut across locations copies first and deletes the source only after the transfer landed. Directory uploads fail closed: the v3 untar <dir> <name> refuses an existing <dir>/<name> before extracting (a microscopic check-to-extract TOCTOU window remains, documented in the probe header), and a stat probe provides the friendly pre-flight refusal. Transfers report throttled progress into a persistent toast (bytes, plus the known total for file uploads) and carry a Cancel action: a shared token kills the in-flight child on the same path as the timeout, partial local temps are unlinked, and the outcome is a neutral "Cancelled" rather than an error. Dropping local files/folders from the OS file manager onto the tree imports them (gtk4::DropTarget on Gdk::FileList only, hover-highlighting the target row): the drop is planned up-front and refused wholesale beyond 256 items or the 512 MiB byte cap (bounded metadata walk, no symlinked-dir following), then per-item recursive copies (local) or streaming uploads (remote) run on the op worker with the same progress/cancel wiring and a failure summary toast. After a mutation only the affected parent directories are re-listed, with a minimal-diff update that keeps surviving rows' identity so expansion state elsewhere in the tree is preserved; a focused managed remote pane that reports its cwd through OSC 7 pulls the tree onto its uniquely matched current host, while a local sibling follows its own local cwd.

The bottom-bar renderer keeps shared composed segment text intact. Only Cwd and Git labels use native middle ellipsization and full-text tooltips, so long metadata can shrink without changing grid, exit or tab segment allocation.

The unified palette keeps fuzzy ranking in a pure data layer and combines actions, metadata-only JSONL command history, YAML/TOML workflows, and the review-first natural-language command entry. Prefixes select one source without creating separate search implementations. A ? request captures one Block TermView, shell/cwd and optional selected Block, sends bounded JSON-encoded untrusted context through a cancellable provider request, and renders Stop/Retry/Regenerate plus the result inline instead of mutating AI Chat history or opening a modal.

The native Shell Agent captures one active Block TermView as its target and surfaces its cwd, provider/model, shell, safety state, turn budget, transcript, and the persistent review-first command-correction toggle. Selected Block data and pane environment metadata are byte-bounded, JSON-encoded, and carried only as explicitly untrusted user-role context; truncation is visible in the context chip. A pure bounded state machine accepts only strict JSON say, run, or done actions and compacts its own transcript to 128 KiB/128 entries; unknown fields, prose, stale proposal IDs, invalid transitions, multi-line/control-character commands, and malformed transitions fail closed. Natural-language suggestions, corrections and Agent proposals share one GTK command-review component for edit validation, live risk presentation, copy and primary-action semantics. Only an explicit Approve & Run may enter the execution transition; Insert only instead records a non-executed manual-review transition and writes no Enter. Execution preserves the exact approved visible text, recomputes risk after edits and common shell wrappers, requires a second exact-command confirmation for recognized dangerous operations, refuses approval unless the pinned prompt is idle and empty, then correlates the resulting finished block before feeding its bounded observation back into the next turn.

The AI panel presents one reusable, 1 MiB-bounded visible transcript/composer and a searchable Chats library rather than retaining a GTK widget tree per conversation. A stable chat ID selects the model rendered into that surface. New chat inserts and selects a new record without mutating older chats; automatic titles and explicit rename, archive/unarchive, and confirmed-delete transitions are model operations. Draft text, provider-bound Block context, archive state, request generation, cancellation and retry payloads belong to each chat, not to the shared widgets. Active and pending-retry Block context is always represented by a clearable chip; shutdown promotes memory-only Block retry state into durable draft/context.

Asynchronous boundaries

GTK widgets are only mutated on the main context. PTY reads, directory enumeration, Git metadata, notifications, AI/Agent transport, Notebook cells, and remote work happen outside the UI thread and return bounded results. AI transport has a four-request global permit bound, cancellable queueing, bounded request history/response pipes, and a short shutdown barrier that lets the worker kill and reap curl before application teardown. Every AI response carries its originating stable chat ID and request generation: switching chats only changes rendering, while stopping or deleting a chat makes its late response a no-op. Agent turn cancellation uses the same transport token while keeping session cancellation distinct; neither path silently authorizes or reinterprets a late reply. Notebook cells run in isolated process groups, stream stdout/stderr through bounded UI updates, and are cancelled as a group. Callbacks that outlive a tab are explicitly removed or guarded by weak references/generations.

Persistence

Each process owns one active window snapshot. A snapshot becomes available to a future process only after graceful shutdown, using an atomic rename from .active to .state. Launches atomically claim one ready snapshot, stale active snapshots are recovered after their owner PID exits, and legacy tabs.state is migrated without allowing two processes to restore it. Ready snapshots are retained with a bounded count. Tab restore retains at most 32 original tab indices alongside the accepted layouts, remapping the saved active page after invalid or over-budget layouts are rejected. A rejected selection falls back to its nearest surviving predecessor, or the first survivor; metadata appearing after the tab cap is still read without decoding additional layouts. The same snapshot carries AI schema v2: the current selection plus at most 50 stable chat metadata records, each with archive state, draft, provider-bound Block context, and at most 100 complete user/assistant turns. Schema v1 single-chat payloads migrate automatically; malformed or future AI payloads are discarded independently so tab recovery still succeeds. In-flight user turns never become apparently completed restored conversations.

The entire compact AI collection, not each chat independently, has an 8 MiB JSON ceiling. Collection compaction preserves chat metadata and removes only the oldest complete user/assistant pairs, never an in-flight trailing user turn, while marking affected chats truncated so data loss is visible. The line-oriented state envelope keeps the existing 20 MiB Pane/Tab budget and adds a dedicated 64 KiB metadata reserve; exact escaped-size compaction uses that headroom and fails closed against the last known-good file if invariants cannot be met. Persistence redaction is collection-wide and includes active, non-active, archived, and draft/context data. In-flight questions are represented as recoverable drafts, never completed turns, and final window close flushes the draft debounce. Safe mode neither restores nor publishes the collection; --no-restore retains its existing fresh, persistable-workspace behavior.

Window-state parsing records whether complete layouts were omitted without changing the public parser or selected-tab remapping. The startup loader combines this signal with AI-payload rejection and moves the source aside once before autosave. A failed recovery move blocks session writes for the process's immutable active path; normal saves, preserved-workspace AI refreshes, and publication recheck this gate when they execute. Successful actual reload or a genuinely missing source restores write authority. Recovery copies retain the existing eight-file pruning policy; this is bounded recovery, not indefinite archival.

Block-history load failures retain their small failure outcome after GTK consumption and after the last pre-apply save lease, so explicit Retry still chooses reload. Successful loaded documents release their reservations as before; explicit Clear discards failed state. Explicit retry saves carry a fresh-diagnostic request through same-key coalescing and generation checks, while normal autosaves remain deduplicated. Retry-triggered load failures use the persistent history surface.

Unified zone-history persistence binds save authority to the exact path that was restored. Missing, valid-empty, and restored documents permit ordinary writes; unobserved paths, unsupported versions, damaged documents, read failures, and incomplete restores do not. A bounded suffix returned after shedding records or output is distinguished from a genuinely empty document, so closing that pane cannot erase its unreplayed source bytes. A failed restore remains fail-closed through empty shutdown and later writes, even after an external repair, until real replay/reopen occurs. Version detection precedes decoding today's zone schema. A bounded sequence visitor validates every record while retaining at most the newest 64 in a fixed-capacity queue, without trusting size hints or allocating the complete record array. Snapshot-byte compaction runs after count eviction so output is not discarded prematurely; any eviction keeps the restore incomplete and the source file protected. A persistent native pane notice explains preservation and recovery; it does not delete, repair, or reinterpret the file.

Session snapshots and Block history use owner-only Unix permissions. File contents are written to a uniquely created sibling temporary file, synced, atomically renamed, and followed by a directory sync where applicable. Parsers retain legacy compatibility and reject pathological record sizes. Safe mode and fresh-workspace launches do not restore the AI payload. The separate JSONL command index deliberately stores no output and enforces record/file bounds.

Native and Flatpak execution boundary

Native builds execute shells and helper tools directly. Flatpak builds keep the GTK process sandboxed but route interactive shells, SSH, Git metadata, curl, and notifications through a single host module backed by flatpak-spawn --host --watch-bus. Cwd and selected environment values are encoded as argv options before process creation, so VTE and Block PTYs share the same explicit host boundary and cleanup rules. The stable application ID is io.github.beamiter.forge.

The Flatpak is intentionally granted host filesystem and command access because a terminal emulator is not a command sandbox. That authority is documented and validated rather than hidden behind a package that only works inside its own container. Runtime assets live under /app/share/forge; explicit environment paths make them discoverable without copying examples into user data.

Configuration and observability

Configuration is parsed and semantically validated before replacing the active runtime value. Writes take an advisory lock, compare the exact loaded revision, preserve two valid rotating backups, and use private durable atomic replacement. Conflicts and invalid TOML cannot overwrite a newer disk revision, and UI-originated failures are surfaced without discarding the active in-memory value. File monitoring provides hot reload while manual reload remains available. Safe mode bypasses the external config path and environment overrides entirely, creates the built-in VTE profile plus default keymap, and rejects manual reload.

The lightweight logger supports plain levels and target-specific RUST_LOG directives; each line includes relative time, severity, and target. --doctor reports configuration validation/permissions/backups/lock state, display/input environment, optional tools, shell choice, AI provider readiness, workflow/Notebook discovery, remote readiness, and ready/active snapshot counts without network probes or snapshot contents; --json makes diagnostics machine-readable. The support-bundle wrapper enables a path/value-redacted diagnostic mode and adds only non-content file metadata and non-sensitive system characteristics.

Quality gates

CI runs the tracked-text privacy guard, rustfmt, shell syntax/ShellCheck, the full Rust test suite, a dedicated Xvfb-backed GTK display regression for diag_short_ls_block_geometry, strict Clippy, Rustdoc with warnings denied, a release build, and warnings-denied RustSec auditing of both committed lockfiles. The dependency-policy, RustSec, and shell jobs delegate to the same mode-aware security entry point used locally. Dependabot covers Cargo and GitHub Actions. Pure helpers should have unit tests; GTK behavior that still cannot be automated belongs in the acceptance checklist and pull-request validation notes.

Invariants

  1. A visible terminal owns every live PTY; closing a tab terminates its process group.
  2. Background work never directly mutates GTK widgets.
  3. Concurrent processes never claim or overwrite the same ready session snapshot.
  4. Persisted terminal data is bounded, atomically replaced, and owner-only.
  5. Invalid configuration never replaces the last valid runtime configuration.
  6. Generated commands are never submitted to a PTY without an explicit user action.
  7. Agent approval requires the exact pinned Block prompt to be idle and empty; every completed result is correlated to the approved proposal.
  8. New features must preserve both VTE and Block input routing or explicitly document a mode limitation.
  9. An AI response may update only its originating extant chat; switching or deleting that chat cannot redirect or resurrect the response.
  10. Terminal/Block bytes are bounded and carried as untrusted user-role data, never interpolated into AI system instructions.
  11. Stopping AI work terminates its transport child; stopping an Agent model turn never terminates an already approved shell command.

The Files path dialog gives breadcrumb labels middle ellipsization so a long component cannot enlarge the dialog beyond its parent. The full safe display label remains in the tooltip; navigation callbacks retain the original PathBuf. A native vertical ScrolledWindow contains the ancestors while the entry, error and ToolbarView header stay outside it. GTK handles ancestor focus reveal; no idle callback resets the user's scroll position.

Finished-card action surface

Each finished card installs a pane-bound block.show-menu action group. Its permanent Block actions button and pointer context gesture share one weakly guarded menu factory, including selection, prompt-ownership checks and verified submission. A per-card weak popover slot prevents duplicate menus; menus use a bounded scrolling viewport. Restore and undo mount the same action group. Widget pooling removes the group alongside old controllers and child widgets so retired block identities cannot survive reuse.

The compact quick-action strip owns its secondary controls even when their child visibility is disabled; the menu invokes the existing filter/expand/navigation paths rather than forking their state machines. Focus ownership is independent of hover and selection: a focused quick-action strip stays sensitive and visible until focus leaves it.

Directory, branch, timestamp and duration share one expanding context label with a one-character width floor and middle ellipsis. Its tooltip and accessible label retain bounded safe details. This keeps the finished-card header at one row, so the existing height estimator, collapse geometry and virtual-scroll anchoring contract remain unchanged.

Bounded Block record review

block_view/review.rs is a read-only native transient inspector, mounted through the same BlockBackend::install_finished_block_context_menu action group as production, restored, and undo-restored cards. block.review and the menu share one weak window slot per card. The modal window does not participate in terminal layout or scroll virtualization.

Snapshots retain only bounded command/metadata strings, never clone output, textures, or finished widgets. Admission is capped at 64 records and 256 KiB of command bytes; individual metadata fields use a 16 KiB safe-display budget. Any omitted/missing selected record disables atomic clipboard copy. Copy re-resolves every captured ID and compares its command against the current model, refusing the entire selection if stale or display-sanitized. The module has no PTY, command-submission context, or execute/insert callback. Output bytes and line counts are metadata only, and unknown completion/timing remain explicit. GTK tests exercise the actual production action group, response to eviction, repeated open/close, narrow/wide rendering, clipboard order, Escape focus return, and unchanged PTY size/input/scroll anchor.

Block review navigation

The active selection edge owns a compact, text-labelled Review selection shelf, using the same block.review production action as metadata and the menu. It is hidden for unselected cards. The inspector captures only weak references to its invoking history control/card and owner; Close/Escape restores a mapped history target before considering the live VTE. This path does not clear selection, unread state or move the history scroll anchor. Review remains clipboard-only and bounded; output and images remain in the original retained blocks.

Chat presentation

Chat is an explicit TerminalMode::Chat routed through the existing TermView and BlockBackend lifecycle. jterm_core::chat_mode resolves content-free surface ownership; it does not parse or execute commands. The per-pane ChatPresentation keeps the Block history insertion anchor in the transcript and reparents only ActiveBlock's existing native clip between that anchor and the dock. Its canvas is the stable viewport authority, avoiding a history-height feedback loop when input docks. GTK's existing clip owns painting and hit tests. FinishedBlock presentation wraps the original command/output VTE widgets, keeping copy/search and record identity. All Chat CSS is scoped to the selected pane. No shared configuration reload can restyle a live Block or managed-remote pane.

Exact retained-text search preview

The Find bar's Preview action captures RenderBackend::records() through TermView::capture_search_preview, without VTE extraction, search cursors or selection. A failed capture is never published. block_view::search_preview contains the GTK-free snapshot/regex/range model and a single background worker; ui::search_preview owns its independent read-only dialog. The snapshot id is its immutable content revision. Hits carry document identity and UTF-8 ranges plus scalar offsets for TextBuffer; no native-terminal coordinates are inferred.

Only retained completed-record text is searched. Source truncation and missing metadata are disclosed; a truncated retained source is a declared scope, while an interrupted scan is an error without an exact count. All query/navigation callbacks stay bound to one weak pane; detached/dead panes and closed dialogs stop publication. Snapshot and monotonically checked query generations gate worker replies. There is one replaceable request and one replaceable reply slot, so rapid edits cannot create unbounded threads or result queues. Cancellation and the deadline are cooperative around regex operations, not hard preemption.

The rendering path applies a TextTag to the exact display text that was searched. It never drives VTE or programmatically creates a selection. Existing native Find remains a separate feature with its existing cursor semantics. See search preview acceptance for limits and tests.