diff --git a/AGENTS.md b/AGENTS.md index b9a7829..52b9f1c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,7 +46,7 @@ diffui/ │ ├── filelist.py # FileList: tree or risk-ordered files │ └── screens.py # Help, comment composer, agent prompt and commit picker ├── static/ # Preact frontend: app.js, components/, lib/ (markdown.js, utils.js), style.css -└── themes/ # Theme dataclass, 16 themes in definitions.py, current-theme state +└── themes/ # Theme dataclass, 16 themes in definitions.py ``` ## Shared patterns @@ -75,8 +75,8 @@ diffui/ - Agent orchestration: the confirm dialog calls `/api/agent/start`, which only queues a start. The agent spawns when the client opens `/api/agent/ws` and sends `init` with the terminal size, so the PTY begins at the right dimensions. The agent's TUI streams to the `AgentTerminal` panel. Output goes into a 64KB buffer that replays on reconnect. Three seconds without output after activity sends an `idle` message. `_build_agent_context` writes the prompt context file. `/api/agent/run` and `/api/agent/status` are the non-terminal path. The web UI does not call them. - Terminal host: `terminal_host.py` is the only module that names herdr. The rest of `server/`, the TUI and the web UI talk about the agent pane. Callers get an agent's name (`agent_pane_name`) and statuses (`repo_agent_statuses`), never herdr's agent dicts. - Agent pane: inside a herdr pane (`HERDR_WORKSPACE_ID` set), `/api/agent/prompt` returns the workspace's agent as `pane_agent`, and the confirm dialog calls `/api/agent/paste` in place of `/api/agent/start`. The prompt goes to that pane as one bracketed paste. `report_status` sets two sidebar tokens after each state change: `comments=N open` and `reviewed=☑ N/M`. An empty value clears a token. `clear_status` removes both on shutdown. Tests run a fake `herdr` from `PATH`. -- Last turn: `view=last-turn` diffs the tree saved when an agent's latest turn started against the worktree as it is now. `turns.py` polls `repo_agent_statuses` (`herdr agent list`) every second, only when `HERDR_WORKSPACE_ID` is set and `herdr` is on `PATH`. It counts the agents whose `foreground_cwd` (else `cwd`) is inside the repo, in any workspace. `TurnTracker` fires on the sample where the repo goes from resting (`idle`, `done`, or no agent) to `working`. A `blocked` or unknown agent is mid-turn, so working after it starts nothing, and the first sample after startup never fires. On the edge `snapshot_worktree` copies the real index to a temp file, runs `git add -A` and `git write-tree` against the copy, and `write_turn_tree` stores the tree under `refs/worktree/diffui/last-turn`. That ref is per linked worktree and survives a restart. `AppState.last_turn` holds the base tree, a fresh tree of the worktree and `git diff --numstat` between them. `reload_last_turn` only marks it stale. The next read of `last_turn` snapshots, so file events while an agent writes cost nothing until a client asks for the view. Each file list and diff comes from one pair of trees, untracked files included. `GET /api/last-turn` returns the reason an empty view is empty. Limits: herdr flips to `working` a moment after the agent starts writing, and the poll adds up to a second, so those first writes sit in the base tree and do not show. A turn that changes nothing still replaces the previous base. -- Search: `GET /api/search?q=&view=&scope=` returns `files`, `hits` (`path`, `line`, `text`, read from the working tree, case-insensitive, at most `MAX_HITS` = 200) and `truncated`. Files and hits both carry `changed`, whether `view` lists the file. `scope=changed`, the default, covers the files `view_files(view)` lists, minus `.diffuiignore` matches. It matches names as a substring or in-order letters, best first, adds fff's fuzzy matches so a typo still finds its file, reads the first 1 MB of each file and skips binary ones. `scope=all` searches the whole repo through fff, which skips `.gitignore`d and binary files and does not apply `.diffuiignore`. fff's fuzzy name matches are capped at `FILE_CAP` = 30. +- Last turn: `view=last-turn` diffs the tree saved when an agent's latest turn started against the worktree as it is now. `turns.py` polls `repo_agent_statuses` and counts the agents whose `foreground_cwd` (else `cwd`) is inside the repo, in any workspace. `TurnTracker` fires on the sample where the repo goes from resting (`idle`, `done`, or no agent) to `working`. A `blocked` or unknown agent is mid-turn, so working after it starts nothing, and the first sample after startup never fires. `AppState.last_turn` holds the base tree, a fresh tree of the worktree (`snapshot_worktree`) and `git diff --numstat` between them. `reload_last_turn` only marks it stale. The next read of `last_turn` snapshots, so file events while an agent writes cost nothing until a client asks for the view. Each file list and diff comes from one pair of trees, untracked files included. `GET /api/last-turn` returns the reason an empty view is empty. +- Search: `GET /api/search?q=&view=&scope=` returns `files`, `hits` (`path`, `line`, `text`, read from the working tree, at most `MAX_HITS`) and `truncated`. Files and hits both carry `changed`, whether `view` lists the file. `scope=changed`, the default, covers the files `view_files(view)` lists, minus `.diffuiignore` matches. It adds fff's fuzzy matches to the name matches, reads the first 1 MB of each file and skips binary ones. `scope=all` searches the whole repo through fff, which skips `.gitignore`d and binary files and does not apply `.diffuiignore`. fff's fuzzy name matches are capped at `FILE_CAP` = 30. - fff index: `fff_index.finder()` hands out one `FileFinder` for the active repo under a lock, since sync routes run in a threadpool. The first search that needs it builds it, never startup, with `watch=False`, because `events.py` already watches the tree. `_apply_state_updates` calls `mark_stale()` on `files_changed` and `git_changed`, and the next `finder()` rescans before it hands the index out. The index is dropped when the repo changes (`switch_repo`, or a different `get_repo_root()`) and closed in the lifespan shutdown. A search that arrives inside the 400ms debounce misses the write, and the next keystroke finds it. `/api/search` lowercases the query because fff matches a lowercase query case-insensitively. `multi_grep` takes it as plain text where `grep` would read a `*.py` word as a path filter. fff's fuzzy name match returns something for almost any query, so `_fuzzy_names` keeps only scores within 75% of the best and above 8 per character of the longest word. - Markdown is sanitized: `renderMd` in `lib/markdown.js` runs marked's output through an allowlist of tags, attributes and URL schemes before it reaches `dangerouslySetInnerHTML`. marked emits raw HTML, and comment bodies, agent replies and previewed `.md` files all come from outside. Without the allowlist, a branch under review can run script in the diffui origin. Diagram blocks go to kroki.io, the one call that leaves the machine. - Explain: `/api/explain` spawns the configured agent CLI to write an HTML walkthrough to the temp dir. `/api/explain/status` polls it. `/api/explain/view` serves the result and refuses a path outside the temp dir. @@ -87,7 +87,7 @@ diffui/ - `treeOrder()` in `FileTree.js` gives the file order that prev/next file and next-unreviewed follow. `_order_files` in `routes_diff.py` mirrors it for the TUI. Sort by risk turns the tree into one flat list. - The top bar holds navigation and global actions. `FileHeader` above the active viewer holds the per-file ones (view mode, whitespace, blame, resolve all, Mark reviewed). - `FullFileViewer` renders a window of lines. Hunk blocks use `content-visibility: auto`. -- Search is `SearchDialog`, opened with `/` or the command palette. It calls `/api/search` 150ms after the last keystroke, aborts the request it replaces, and `tab` switches `scope`. `handleSearchOpen` in `app.js` opens a hit in the diff and scrolls to the line the diff shows nearest to it (`nearestNewLine`). A file outside the diff sets `browsing` and opens in `FullFileViewer` with `readOnly`, because the server reports an unchanged file as all added. `browsing` swaps `FileHeader` for a short one, makes `handleToggleReview` return, stops `fetchFiles` from resetting the active file, and keeps `activeFile` out of the session save. It ends when the file joins the diff or another file is picked. +- `SearchDialog` calls `/api/search` 150ms after the last keystroke and aborts the request it replaces. `handleSearchOpen` in `app.js` scrolls to the line the diff shows nearest to a hit (`nearestNewLine`). A file outside the diff sets `browsing` and opens in `FullFileViewer` with `readOnly`, because the server reports an unchanged file as all added. `browsing` swaps `FileHeader` for a short one, makes `handleToggleReview` return, stops `fetchFiles` from resetting the active file, and keeps `activeFile` out of the session save. It ends when the file joins the diff or another file is picked. - Keep the accessibility basics: `role="treeitem"` on tree items, focus trapping in the settings dialog, `:focus-visible` outlines on buttons, and `+`/`-` prefixes next to the diff colors. ### Design system @@ -117,16 +117,16 @@ After a change: - Thin client: the TUI holds no review logic. Comments, reviewed state, risk scores, file order, the agent prompt, the pane paste and the sidebar status all stay in `server/`. When the TUI needs data the API lacks, add or extend an endpoint. Pygments highlighting of the `text` the diff endpoint returns is the one thing computed client-side, because it is presentation. `_next_unreviewed()` in `tui/app.py` is the exception: it takes the first unreviewed file in the order `/api/files` returned. - Everything the server returns passes through `_clean` in `client.py`, which strips control characters. Diff text and comments come from the branch under review, and an escape sequence in them would reach the terminal as a command. A new `ApiClient` method must return through `_json`. - `LocalServer` runs `create_app` under uvicorn in a background thread so the lifespan starts the watcher and status reporting. The client uses `http://localhost:`, which `_LocalOriginOnly` accepts. -- Layout: the diff is on the left and `FileList` on the right. `z` hides the list. Below 110 columns, `HORIZONTAL_BREAKPOINTS` on `DiffuiApp` adds `-narrow` to the screen and the list stacks below the diff. `DiffuiApp.view` is the scope (`all`, `working`, `last-turn` or a commit sha) and goes to `/api/files`, `/api/diff`, `/api/file` and `/api/search` as `view`. `u`, `b` and `t` switch it and `C` picks a commit. The header shows it, and an empty last-turn scope shows the server's reason. +- Layout: below the breakpoint, `HORIZONTAL_BREAKPOINTS` on `DiffuiApp` adds `-narrow` to the screen. `DiffuiApp.view` is the scope (`all`, `working`, `last-turn` or a commit sha) and goes to `/api/files`, `/api/diff`, `/api/file` and `/api/search` as `view`. - File order: `/api/files?order=tree|risk`. `hide_ignored` drops `.diffuiignore` matches. Each entry carries `dir` and `open_comment_count`. - `DiffView` is a `ScrollView` that renders rows on demand. Pygments runs on a row the first time it is drawn, so a few thousand lines open at once. A comment's `line_index` is the diff line's `index`, the same anchor the web UI uses. Threads whose line left the diff are listed after the last hunk. - Live updates come from the `/api/events` SSE stream. The TUI refetches files, the diff or comments per event, so an agent writing `comments.json` shows up without a keypress. - Open in browser (`o`) writes `activeFile` to `/api/session`, which the web UI restores on load. - Range comments send what the web UI sends for a selection across lines: `line_index` is the first diff line's `index`, `sel_end_index` the last, `sel_start` 0, `sel_end` the last line's text length, and `selected_text` the lines joined by newlines. Threads anchor under the first line. A single line sends none of the selection fields. -- Split view: `build_rows(..., split=True)` uses `split_pairs` to pair a run of removes with the run of adds that follows, as `splitHunkLines` does in `SplitDiffViewer.js`. A split `Row` has `old` and `new` (a context line is both), and `row.line` is the new one when there is one. `DiffView.target` picks the line a comment goes against. `side` is None for the keyboard (new side), and `tab` or the pointer position sets it. The old side is used for a removed line. A range follows one side and sorts its lines by `index`, since paired rows are not in diff order. Split needs `SPLIT_MIN_WIDTH` columns in the diff pane. Below that, `|` keeps unified and says so, and a resize that crosses the width rebuilds the rows. Long lines are cut at the side's edge, because split has no horizontal scroll. -- Find in file is client-side: `Find` matches `code_of(line)` of each row, case-insensitive, and `code_segments` draws the spans. Once the bar closes with a query still set, `n` / `N` step the matches and `esc` clears them. The `find_step` and `clear_find` bindings sit before the thread ones, and `check_action` enables them only while matches show, so `n` is next thread otherwise. -- Search is `SearchScreen`, which calls `/api/search` 150ms after the last keystroke. `tab` switches `scope` between `changed` and `all`. A hit opens the file, and `DiffView.goto_line` moves to the nearest new-side line when the diff does not show the hit's line. -- Files outside the diff: a result whose path is not in the view's file list sets `DiffuiApp.browsing`. `_load_diff` takes the file from `/api/file` (its `text` per line, since `html` is for the web UI) and wraps it as one hunk of context lines. `DiffView.readonly` then makes `context` return `readonly`, which matches no `c`, `x` or `v` binding, and turns off hover, gutter presses, range selection and split. `check_action` hides Reviewed and Hunks. `_refresh_files` leaves a browsed file open on file events and stops browsing once the file joins the diff. A scope change, or `left` / `right`, returns to the diff's files. +- Split view: `build_rows(..., split=True)` uses `split_pairs` to pair a run of removes with the run of adds that follows, as `splitHunkLines` does in `SplitDiffViewer.js`. A split `Row` has `old` and `new` (a context line is both), and `row.line` is the new one when there is one. `DiffView.target` picks the line a comment goes against. `side` is None for the keyboard (new side) and is set by `tab` or the pointer. A range follows one side and sorts its lines by `index`, since paired rows are not in diff order. A resize that crosses `SPLIT_MIN_WIDTH` rebuilds the rows. Long lines are cut at the side's edge, because split has no horizontal scroll. +- Find in file is client-side: `Find` matches `code_of(line)` of each row, and `code_segments` draws the spans. The `find_step` and `clear_find` bindings sit before the thread ones, and `check_action` enables them only while matches show, so `n` is next thread otherwise. +- `SearchScreen` calls `/api/search` 150ms after the last keystroke. `DiffView.goto_line` moves to the nearest new-side line when the diff does not show the hit's line. +- Files outside the diff: a result whose path is not in the view's file list sets `DiffuiApp.browsing`. `_load_diff` takes the file from `/api/file` (its `text` per line, since `html` is for the web UI) and wraps it as one hunk of context lines. `DiffView.readonly` then makes `context` return `readonly`, which matches no `c`, `x` or `v` binding, and turns off hover, gutter presses, range selection and split. `check_action` hides Reviewed and Hunks. `_refresh_files` leaves a browsed file open on file events and stops browsing once the file joins the diff. - Hover and drag state live in `DiffView` (`hover`, `range_start`). Hover changes refresh only the affected rows. A press on the number column captures the mouse so the drag keeps tracking outside the widget, and the `Click` that follows the release is swallowed so it cannot open a second composer. - The footer's leading hints come from `DiffuiApp.check_action`. Bindings share a key and differ in their `'range'`, `'thread'`, `'line'` or `'none'` parameter, and `DiffView.context` picks the one shown. `ContextFooter` fills in the line count, because a `Binding` description is fixed text. @@ -172,7 +172,7 @@ All in `~/.config/diffui/`: | File | Scope | Format | | --- | --- | --- | -| `settings.json` | Global | `{theme, editor, view_mode, user_name, agent_cli, font_size, word_wrap, keybindings}` | +| `settings.json` | Global | `{theme, editor, user_name, agent_cli, font_size, word_wrap, keybindings}` | | `{repo}/{branch}/reviewed.json` | Per branch | `{"path": mtime}` | | `{repo}/{branch}/comments.json` | Per branch | `{"path": [{...}]}` | | `{repo}/{branch}/session.json` | Per branch | `{activeFile, diffMode, showFileTree, showReviewed, scrollPositions}` | diff --git a/README.md b/README.md index a2206eb..8d37e88 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ You get syntax-highlighted diffs, inline comment threads and risk scoring. If yo Use it before you push. Review what your agent just wrote while nothing is on a remote yet, or look at your own branch in a real diff UI without opening a draft MR. GitHub or GitLab review still happens after. -It runs as a local web server with a Preact frontend, or as a terminal UI with `diffui --tui`. There is no build step and no accounts. Your code stays on your machine with one exception: a markdown file with a mermaid or plantuml block is rendered by kroki.io, so that block's source is sent there when you preview the file. The web UI loads its frontend libraries from a CDN, so the first paint needs a network. +It runs as a local web server with a Preact frontend, or as a terminal UI with `diffui --tui`. There is no build step and no accounts. Your code stays on your machine with one exception: a markdown file with a diagram block (mermaid, plantuml, graphviz, d2 and others) is rendered by kroki.io, so that block's source is sent there when you preview the file. The web UI loads its frontend libraries from a CDN, so the first paint needs a network. ## Install @@ -76,7 +76,7 @@ The browser has the agent terminal panel, Explain changes, markdown and mermaid - `ctrl+f` searches within the diff and highlights the matches. `ctrl+g` jumps to a line. `ctrl+shift+f`, or the Search files button, filters the file list. - `/` opens a search over file names and text. It starts on the files in the current scope, and `tab` switches to the whole repo, which skips `.gitignore`d and binary files. Results group by file with line numbers, up to 200 lines. Enter opens the file at that line, and when the diff does not show the line, the view goes to the nearest one it does. Files that are not in the diff are dimmed and marked "not in diff". They open read-only in the file view, with no comments and no Mark reviewed, and picking a file from the explorer returns to the diff. The terminal UI has the same search, described in [Search and find](#search-and-find). - Files are scored for risk from their path and size: migration, infra, schema and deploy paths, config files, large deletions, high churn and test removal. Medium and high risk files get a colored dot in the file tree, with the level in the tooltip. `s` sorts by risk. -- A `.diffuiignore` file in the repo root hides matching files from the file list and from search. It takes one glob per line, and `#` starts a comment. `diffui --json` still lists them. +- A `.diffuiignore` file in the repo root hides matching files from the file list and from changed-scope search. Searching the whole repo does not apply it. It takes one glob per line, and `#` starts a comment. `diffui --json` still lists them. ## Comments diff --git a/diffui/cli.py b/diffui/cli.py index 22e7e89..d2b5193 100644 --- a/diffui/cli.py +++ b/diffui/cli.py @@ -18,7 +18,7 @@ ) -def _find_free_port() -> int: +def find_free_port() -> int: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: s.bind(("", 0)) return s.getsockname()[1] @@ -122,7 +122,7 @@ def main() -> None: from diffui.server.app import create_app - port = _find_free_port() + port = find_free_port() app = create_app(repos, active_index=active_index) import uvicorn diff --git a/diffui/diff.py b/diffui/diff.py index a4efece..26d4ca9 100644 --- a/diffui/diff.py +++ b/diffui/diff.py @@ -5,6 +5,7 @@ from pygments.lexers import get_lexer_for_filename from pygments.lexers.special import TextLexer +from pygments.util import ClassNotFound _META_PREFIXES = ("diff ", "index ") _HUNK_RE = re.compile(r"^@@ -(\d+)") @@ -20,7 +21,7 @@ def is_meta_line(line: str) -> bool: def get_lexer(file_path: str): try: return get_lexer_for_filename(file_path, stripnl=False, stripall=False) - except Exception: + except ClassNotFound: return TextLexer(stripnl=False, stripall=False) @@ -79,11 +80,7 @@ def classify_line(line: str) -> str: def strip_diff_prefix(line: str) -> str: - if line.startswith("+") or line.startswith("-"): - return line[1:] - if line.startswith(" "): - return line[1:] - return line + return line[1:] if line[:1] in ("+", "-", " ") else line def _word_offsets(words: list[str]) -> list[int]: diff --git a/diffui/git_utils.py b/diffui/git_utils.py index e07e3d3..33568ce 100644 --- a/diffui/git_utils.py +++ b/diffui/git_utils.py @@ -20,7 +20,7 @@ def set_active_repo(path: Path) -> None: _active_repo_root = path get_repo_root.cache_clear() _resolved_repo_root.cache_clear() - _cached_current_branch.cache_clear() + clear_branch_cache() def discover_sibling_repos(current_root: Path) -> list[Path]: @@ -388,12 +388,6 @@ def get_head_sha() -> str: return result.stdout.strip() if result.returncode == 0 else "" -def get_git_user_name() -> str: - result = _git("config", "user.name") - name = result.stdout.strip() - return name if name else "User" - - def short_name(path: str) -> str: return path.split("/")[-1] diff --git a/diffui/server/app.py b/diffui/server/app.py index c554ddc..d184502 100644 --- a/diffui/server/app.py +++ b/diffui/server/app.py @@ -22,6 +22,7 @@ from diffui.server.routes_settings import router as settings_router from diffui.server.state import app_state from diffui.server.terminal_host import clear_status, report_status +from diffui.server.theme_css import generate_css_vars from diffui.server.turns import router as turns_router from diffui.server.turns import start_turn_poller from diffui.themes import ALL_THEMES @@ -100,8 +101,6 @@ def list_themes(): @app.get("/api/theme/css") def get_theme_css(): - from diffui.server.theme_css import generate_css_vars - return {"css": generate_css_vars(app_state.theme)} _index_html = (_STATIC_DIR / "index.html").read_text() diff --git a/diffui/server/events.py b/diffui/server/events.py index 00250e3..39332d2 100644 --- a/diffui/server/events.py +++ b/diffui/server/events.py @@ -11,11 +11,16 @@ from diffui.git_utils import ( _comments_path, + get_diff_numstat, + get_file_mtime, get_git_dir, get_repo_root, + get_working_changed_files, load_comments, + save_reviewed, ) from diffui.server import fff_index +from diffui.server.routes_diff import clear_diff_cache from diffui.server.state import app_state from diffui.server.terminal_host import report_status @@ -46,12 +51,9 @@ def _broadcast(events: list[str]) -> None: pass for ws in list(_ws_clients): - try: - task = asyncio.create_task(ws.send_text(data)) - _bg_tasks.add(task) - task.add_done_callback(_bg_tasks.discard) - except Exception: - _ws_clients.discard(ws) + task = asyncio.create_task(ws.send_text(data)) + _bg_tasks.add(task) + task.add_done_callback(_bg_tasks.discard) def _classify_changes(changes: set[tuple], git_dir: str, comments_path: str) -> list[str]: @@ -67,8 +69,6 @@ def _classify_changes(changes: set[tuple], git_dir: str, comments_path: str) -> def _invalidate_stale_reviews() -> None: - from diffui.git_utils import get_file_mtime, save_reviewed - stale = [p for p, t in app_state.reviewed.items() if get_file_mtime(p) != t] if stale: for p in stale: @@ -83,8 +83,6 @@ def _apply_state_updates(events: list[str]) -> list[str]: prev_branch = app_state.branch_name prev_comments = app_state.comments app_state.reload_repo_state() - from diffui.server.routes_diff import clear_diff_cache - clear_diff_cache() _invalidate_stale_reviews() if "files_changed" not in events: @@ -97,8 +95,6 @@ def _apply_state_updates(events: list[str]) -> list[str]: if app_state.branch_name != prev_branch: restart_watcher() elif "files_changed" in events: - from diffui.git_utils import get_diff_numstat, get_working_changed_files - app_state.numstat = get_diff_numstat(app_state.merge_base) app_state.working_files = get_working_changed_files() app_state.reload_last_turn() @@ -160,15 +156,6 @@ async def _watch_loop(cancel: asyncio.Event) -> None: # --- SSE fallback --- -async def _event_stream(queue: asyncio.Queue): - try: - while True: - msg = await queue.get() - yield msg - except asyncio.CancelledError: - pass - - @router.get("/events") async def sse_events(): queue: asyncio.Queue = asyncio.Queue(maxsize=32) @@ -177,8 +164,8 @@ async def sse_events(): async def generate(): try: yield 'data: {"events": ["connected"]}\n\n' - async for msg in _event_stream(queue): - yield msg + while True: + yield await queue.get() finally: _sse_clients.discard(queue) @@ -206,10 +193,10 @@ async def websocket_endpoint(ws: WebSocket): data = await ws.receive_text() try: msg = json.loads(data) - if msg.get("type") == "ping": - await ws.send_text(json.dumps({"type": "pong"})) - except (json.JSONDecodeError, KeyError): - pass + except json.JSONDecodeError: + continue + if msg.get("type") == "ping": + await ws.send_text(json.dumps({"type": "pong"})) except WebSocketDisconnect: pass finally: diff --git a/diffui/server/highlight.py b/diffui/server/highlight.py index 6ceb67f..aa31792 100644 --- a/diffui/server/highlight.py +++ b/diffui/server/highlight.py @@ -50,42 +50,18 @@ def _apply_word_highlights(code_html: str, ranges: list[tuple[int, int]], code_p if in_highlight: result.append("") in_highlight = False - end_tag = code_html.find(">", i) - if end_tag == -1: - result.append(code_html[i:]) - break - result.append(code_html[i : end_tag + 1]) - i = end_tag + 1 - continue - if code_html[i] == "&": - semi = code_html.find(";", i) - if semi == -1: - result.append(code_html[i]) - plain_idx += 1 - i += 1 - continue - entity = code_html[i : semi + 1] + end = code_html.index(">", i) + 1 + result.append(code_html[i:end]) + else: + # highlight_line_html escapes its input, so every `&` opens a complete entity. + end = code_html.index(";", i) + 1 if code_html[i] == "&" else i + 1 should_mark = plain_idx in marked_chars - if should_mark and not in_highlight: - result.append("") - in_highlight = True - elif not should_mark and in_highlight: - result.append("") - in_highlight = False - result.append(entity) + if should_mark != in_highlight: + result.append("" if should_mark else "") + in_highlight = should_mark + result.append(code_html[i:end]) plain_idx += 1 - i = semi + 1 - continue - should_mark = plain_idx in marked_chars - if should_mark and not in_highlight: - result.append("") - in_highlight = True - elif not should_mark and in_highlight: - result.append("") - in_highlight = False - result.append(code_html[i]) - plain_idx += 1 - i += 1 + i = end if in_highlight: result.append("") return "".join(result) @@ -121,7 +97,7 @@ def parse_diff_to_json(diff_text: str, file_path: str, theme: Theme, with_html: line_type = classify_line(line) if line_type == "hunk": continue - old_num, new_num = numbers[i] if i < len(numbers) else (None, None) + old_num, new_num = numbers[i] entry = {"index": i, "type": line_type, "old_num": old_num, "new_num": new_num, "text": line} if with_html: @@ -149,21 +125,16 @@ def highlight_file_to_json(content: str, diff_text: str, file_path: str, theme: lexer = get_lexer(file_path) file_lines = content.splitlines() - changed_lines: dict[int, str] = {} - if diff_text: - numbers = parse_line_numbers(diff_text) - diff_lines = diff_text.splitlines() - for i, line in enumerate(diff_lines): - line_type = classify_line(line) - if i < len(numbers): - _, new_num = numbers[i] - if new_num and line_type == "add": - changed_lines[int(new_num)] = "add" + added = { + int(new_num) + for (_, new_num), line in zip(parse_line_numbers(diff_text), diff_text.splitlines(), strict=True) + if new_num and classify_line(line) == "add" + } result_lines: list[dict] = [] for i, line in enumerate(file_lines): line_num = i + 1 - line_type = changed_lines.get(line_num, "context") + line_type = "add" if line_num in added else "context" entry = {"num": line_num, "text": line, "type": line_type} if with_html: entry["html"] = highlight_line_html(line, lexer, theme.syntax) diff --git a/diffui/server/models.py b/diffui/server/models.py index b14037a..f5850e0 100644 --- a/diffui/server/models.py +++ b/diffui/server/models.py @@ -42,7 +42,6 @@ class RepoSwitch(BaseModel): class SettingsUpdate(BaseModel): theme_index: int | None = None editor: str | None = Field(default=None, pattern="^(code|cursor|vim|nvim)$") - view_mode: str | None = None user_name: str | None = None agent_cli: str | None = Field(default=None, pattern="^(claude|codex|opencode|cursor)$") font_size: int | None = Field(default=None, ge=8, le=32) diff --git a/diffui/server/routes_agent_terminal.py b/diffui/server/routes_agent_terminal.py index 4d847f1..3f57ba4 100644 --- a/diffui/server/routes_agent_terminal.py +++ b/diffui/server/routes_agent_terminal.py @@ -47,11 +47,7 @@ def _append_output(data: bytes) -> None: def _pending_is_stale() -> bool: if _pending_start is None: return True - return time.monotonic() - _pending_start.get("created", 0.0) > _PENDING_TTL - - -def _flush_buffer() -> bytes: - return b"".join(_output_buffer) + return time.monotonic() - _pending_start["created"] > _PENDING_TTL def _is_running() -> bool: @@ -188,7 +184,7 @@ async def agent_ws(ws: WebSocket): if _agent_proc is not None and _agent_pty_fd is not None: pty_fd = _agent_pty_fd proc = _agent_proc - replay = _flush_buffer() + replay = b"".join(_output_buffer) if replay: await ws.send_json({"type": "output", "data": replay.decode("utf-8", errors="replace")}) await ws.send_json({"type": "started", "pid": proc.pid}) diff --git a/diffui/server/routes_diff.py b/diffui/server/routes_diff.py index bb2c555..bf88e4e 100644 --- a/diffui/server/routes_diff.py +++ b/diffui/server/routes_diff.py @@ -1,12 +1,16 @@ from __future__ import annotations +import base64 +import mimetypes import threading from fnmatch import fnmatch from fastapi import APIRouter from diffui.git_utils import ( + get_blame, get_commit_diff, + get_file_content, get_file_mtime, get_full_diff, get_repo_root, @@ -16,7 +20,7 @@ repo_relative, short_name, ) -from diffui.server.highlight import parse_diff_to_json +from diffui.server.highlight import highlight_file_to_json, parse_diff_to_json from diffui.server.state import app_state LAST_TURN = "last-turn" @@ -69,10 +73,7 @@ def _get_diff(path: str, view: str, context: int = 3, ignore_whitespace: bool = if stale is not None: del _diff_cache[stale] if len(_diff_cache) >= _MAX_CACHE: - try: - del _diff_cache[next(iter(_diff_cache))] - except StopIteration: - pass + del _diff_cache[next(iter(_diff_cache))] _diff_cache[cache_key] = result return result @@ -183,9 +184,6 @@ def get_diff(path: str, view: str = "all", context: int = 3, ignore_whitespace: @router.get("/file/{path:path}") def get_file(path: str, view: str = "all", html: bool = True): - from diffui.git_utils import get_file_content - from diffui.server.highlight import highlight_file_to_json - path = repo_relative(path) content = get_file_content(path) diff_text = _get_diff(path, view) @@ -194,17 +192,11 @@ def get_file(path: str, view: str = "all", html: bool = True): @router.get("/blame/{path:path}") def get_blame_data(path: str): - from diffui.git_utils import get_blame - return get_blame(repo_relative(path)) @router.get("/preview/{path:path}") def get_preview(path: str): - import mimetypes - - from diffui.git_utils import get_file_content, get_repo_root - path = repo_relative(path) mime, _ = mimetypes.guess_type(path) if mime and mime.startswith("image/"): @@ -214,8 +206,6 @@ def get_preview(path: str): size = full_path.stat().st_size if size > 10 * 1024 * 1024: return {"type": "image", "exists": True, "too_large": True} - import base64 - data = base64.b64encode(full_path.read_bytes()).decode() return {"type": "image", "exists": True, "data_url": f"data:{mime};base64,{data}"} content = get_file_content(path) diff --git a/diffui/server/routes_repo.py b/diffui/server/routes_repo.py index c21ca3f..7f35f9c 100644 --- a/diffui/server/routes_repo.py +++ b/diffui/server/routes_repo.py @@ -6,7 +6,9 @@ from diffui.git_utils import ( current_branch, + get_head_sha, get_main_branch, + get_remote_url, get_repo_root, repo_has_changes, set_active_repo, @@ -14,6 +16,7 @@ from diffui.server import fff_index from diffui.server.events import restart_watcher from diffui.server.models import RepoSwitch +from diffui.server.routes_diff import clear_diff_cache from diffui.server.state import app_state from diffui.server.terminal_host import report_status @@ -55,8 +58,6 @@ def switch_repo(body: RepoSwitch): app_state.active_repo_index = body.index set_active_repo(app_state.repos[body.index]) app_state.reload_repo_state() - from diffui.server.routes_diff import clear_diff_cache - clear_diff_cache() fff_index.close() restart_watcher() @@ -66,8 +67,6 @@ def switch_repo(body: RepoSwitch): @router.get("/branch") def get_branch(): - from diffui.git_utils import get_head_sha, get_remote_url - return { "name": current_branch(), "main_branch": get_main_branch(), diff --git a/diffui/server/routes_settings.py b/diffui/server/routes_settings.py index 95f6186..37d8d3d 100644 --- a/diffui/server/routes_settings.py +++ b/diffui/server/routes_settings.py @@ -8,60 +8,26 @@ from diffui.server.models import EditorOpen, SessionUpdate, SettingsUpdate from diffui.server.state import app_state from diffui.server.theme_css import generate_css_vars -from diffui.themes import ALL_THEMES, set_current_theme +from diffui.themes import ALL_THEMES router = APIRouter(prefix="/api") @router.get("/settings") def get_settings(): - return { - "theme": ALL_THEMES[app_state.theme_index].name, - "theme_index": app_state.theme_index, - "editor": app_state.editor, - "view_mode": app_state.view_mode, - "user_name": app_state.user_name, - "agent_cli": app_state.agent_cli, - "font_size": app_state.font_size, - "word_wrap": app_state.word_wrap, - "keybindings": app_state.keybindings, - } + return {**app_state.settings(), "theme_index": app_state.theme_index} @router.put("/settings") def update_settings(body: SettingsUpdate): - if body.theme_index is not None: - idx = body.theme_index - if 0 <= idx < len(ALL_THEMES): - app_state.theme_index = idx - set_current_theme(ALL_THEMES[idx]) - if body.editor is not None: - app_state.editor = body.editor - if body.view_mode is not None: - app_state.view_mode = body.view_mode - if body.user_name is not None: - app_state.user_name = body.user_name - if body.agent_cli is not None: - app_state.agent_cli = body.agent_cli - if body.font_size is not None: - app_state.font_size = body.font_size - if body.word_wrap is not None: - app_state.word_wrap = body.word_wrap - if body.keybindings is not None: - app_state.keybindings = body.keybindings - - save_settings( - { - "theme": ALL_THEMES[app_state.theme_index].name, - "editor": app_state.editor, - "view_mode": app_state.view_mode, - "user_name": app_state.user_name, - "agent_cli": app_state.agent_cli, - "font_size": app_state.font_size, - "word_wrap": app_state.word_wrap, - "keybindings": app_state.keybindings, - } - ) + updates = body.model_dump(exclude_none=True) + idx = updates.pop("theme_index", None) + if idx is not None and 0 <= idx < len(ALL_THEMES): + app_state.theme_index = idx + for key, value in updates.items(): + setattr(app_state, key, value) + + save_settings(app_state.settings()) return {"ok": True, "css": generate_css_vars(app_state.theme)} diff --git a/diffui/server/state.py b/diffui/server/state.py index 9fb392d..15a0fbc 100644 --- a/diffui/server/state.py +++ b/diffui/server/state.py @@ -10,9 +10,12 @@ current_branch, get_branch_commits, get_changed_files, + get_diff_numstat, get_main_branch, get_merge_base, + get_repo_root, get_tree_numstat, + get_working_changed_files, is_comment_open, load_comments, load_reviewed, @@ -20,7 +23,7 @@ read_turn_tree, snapshot_worktree, ) -from diffui.themes import ALL_THEMES, DEFAULT_THEME, get_current_theme, set_current_theme +from diffui.themes import ALL_THEMES, DEFAULT_THEME log = logging.getLogger(__name__) @@ -47,16 +50,13 @@ def __init__(self) -> None: self._last_turn_stale = True self.reviewed: dict[str, float] = {} self.comments: dict[str, list[dict]] = {} - self.current_view: str = "all" self.branch_name: str = "" saved = load_settings() theme_name = saved.get("theme", DEFAULT_THEME.name) theme_index = next((i for i, t in enumerate(ALL_THEMES) if t.name == theme_name), 0) - set_current_theme(ALL_THEMES[theme_index]) self.editor: str = saved.get("editor", "code") - self.view_mode: str = saved.get("view_mode", "diff") self.user_name: str = saved.get("user_name", "User") self.agent_cli: str = saved.get("agent_cli", "claude") self.font_size: int = saved.get("font_size", 13) @@ -72,14 +72,11 @@ def reload_repo_state(self) -> None: self.merge_base = get_merge_base(main_branch) self.commits = get_branch_commits(self.merge_base) self.all_files = get_changed_files(self.merge_base) - from diffui.git_utils import get_diff_numstat, get_repo_root, get_working_changed_files - self.numstat = get_diff_numstat(self.merge_base) self.working_files = get_working_changed_files() self.reload_last_turn() self.reviewed = load_reviewed() self.comments = load_comments() - self.current_view = "all" self._load_ignore_patterns(get_repo_root()) def reload_last_turn(self) -> None: @@ -124,13 +121,20 @@ def reviewed_count(self) -> int: def open_comments(self) -> list[dict]: return [{**c, "_file": f} for f, cs in self.comments.items() for c in cs if is_comment_open(c)] - def reload_branch_name(self) -> str: - self.branch_name = current_branch() - return self.branch_name - @property def theme(self): - return get_current_theme() + return ALL_THEMES[self.theme_index] + + def settings(self) -> dict: + return { + "theme": self.theme.name, + "editor": self.editor, + "user_name": self.user_name, + "agent_cli": self.agent_cli, + "font_size": self.font_size, + "word_wrap": self.word_wrap, + "keybindings": self.keybindings, + } app_state = AppState() diff --git a/diffui/themes/__init__.py b/diffui/themes/__init__.py index 079f5e1..0ef8446 100644 --- a/diffui/themes/__init__.py +++ b/diffui/themes/__init__.py @@ -1,24 +1,10 @@ -from diffui.themes.definitions import ALL_THEMES, CATPPUCCIN_MOCHA +from diffui.themes.definitions import ALL_THEMES from diffui.themes.theme import Theme DEFAULT_THEME: Theme = ALL_THEMES[0] -_current_theme: Theme = DEFAULT_THEME - - -def get_current_theme() -> Theme: - return _current_theme - - -def set_current_theme(theme: Theme) -> None: - global _current_theme - _current_theme = theme - __all__ = [ "ALL_THEMES", - "CATPPUCCIN_MOCHA", "DEFAULT_THEME", "Theme", - "get_current_theme", - "set_current_theme", ] diff --git a/diffui/themes/definitions.py b/diffui/themes/definitions.py index 07f9f15..df25abd 100644 --- a/diffui/themes/definitions.py +++ b/diffui/themes/definitions.py @@ -24,8 +24,6 @@ comment_bg="#1a1d22", comment_header_bg="#20242a", comment_accent="#6f9fd8", - delete_bg="#281a1d", - delete_hover="#35222a", delete_fg="#e07b7b", warn_bg="#2a2516", warn_hover="#382f1c", @@ -79,8 +77,6 @@ comment_bg="#242438", comment_header_bg="#2a2a40", comment_accent="#89b4fa", - delete_bg="#3a1a1a", - delete_hover="#4d2626", delete_fg="#f38ba8", warn_bg="#3a3a1a", warn_hover="#4d4d26", @@ -134,8 +130,6 @@ comment_bg="#161b22", comment_header_bg="#1c2128", comment_accent="#58a6ff", - delete_bg="#2d1117", - delete_hover="#3d1a1f", delete_fg="#f85149", warn_bg="#2d2a11", warn_hover="#3d3a1f", @@ -189,8 +183,6 @@ comment_bg="#21222c", comment_header_bg="#2d2f3d", comment_accent="#bd93f9", - delete_bg="#3e1a1a", - delete_hover="#502626", delete_fg="#ff5555", warn_bg="#3a3a1a", warn_hover="#4d4d26", @@ -244,8 +236,6 @@ comment_bg="#21252b", comment_header_bg="#2c313c", comment_accent="#61afef", - delete_bg="#3a1e1e", - delete_hover="#4d2a2a", delete_fg="#e06c75", warn_bg="#2e2a1a", warn_hover="#3d3726", @@ -299,8 +289,6 @@ comment_bg="#00212b", comment_header_bg="#073642", comment_accent="#268bd2", - delete_bg="#3a1a0a", - delete_hover="#4d2612", delete_fg="#dc322f", warn_bg="#2a2a0a", warn_hover="#3a3a12", @@ -354,8 +342,6 @@ comment_bg="#1d2021", comment_header_bg="#32302f", comment_accent="#83a598", - delete_bg="#3c1f1f", - delete_hover="#4d2a2a", delete_fg="#fb4934", warn_bg="#3c3a1a", warn_hover="#4d4d26", @@ -409,8 +395,6 @@ comment_bg="#242933", comment_header_bg="#2e3440", comment_accent="#88c0d0", - delete_bg="#3a1a1a", - delete_hover="#4d2626", delete_fg="#bf616a", warn_bg="#3a3a1a", warn_hover="#4d4d26", @@ -464,8 +448,6 @@ comment_bg="#16161e", comment_header_bg="#1e2030", comment_accent="#7aa2f7", - delete_bg="#2d1520", - delete_hover="#3d1f2a", delete_fg="#f7768e", warn_bg="#2e2a1a", warn_hover="#3d3726", @@ -519,8 +501,6 @@ comment_bg="#1f1d2e", comment_header_bg="#26233a", comment_accent="#c4a7e7", - delete_bg="#2d1520", - delete_hover="#3d1f2a", delete_fg="#eb6f92", warn_bg="#2e2a1a", warn_hover="#3d3726", @@ -574,8 +554,6 @@ comment_bg="#221f22", comment_header_bg="#2d2a2e", comment_accent="#78dce8", - delete_bg="#3e1a1e", - delete_hover="#502628", delete_fg="#ff6188", warn_bg="#3e3a1a", warn_hover="#4d4d26", @@ -629,8 +607,6 @@ comment_bg="#16161d", comment_header_bg="#2a2a37", comment_accent="#7e9cd8", - delete_bg="#3a1e1e", - delete_hover="#4d2a2a", delete_fg="#e82424", warn_bg="#2e2a1a", warn_hover="#3d3726", @@ -684,8 +660,6 @@ comment_bg="#272e33", comment_header_bg="#343f44", comment_accent="#a7c080", - delete_bg="#3a1e1e", - delete_hover="#4d2a2a", delete_fg="#e67e80", warn_bg="#3a3a1a", warn_hover="#4d4d26", @@ -739,8 +713,6 @@ comment_bg="#0a0d12", comment_header_bg="#131720", comment_accent="#e6b450", - delete_bg="#3a1e1e", - delete_hover="#4d2a2a", delete_fg="#f07178", warn_bg="#2e2a1a", warn_hover="#3d3726", @@ -794,8 +766,6 @@ comment_bg="#2a273f", comment_header_bg="#393552", comment_accent="#c4a7e7", - delete_bg="#2d1520", - delete_hover="#3d1f2a", delete_fg="#eb6f92", warn_bg="#2e2a1a", warn_hover="#3d3726", @@ -849,8 +819,6 @@ comment_bg="#e6e9ef", comment_header_bg="#dce0e8", comment_accent="#1e66f5", - delete_bg="#ecdad4", - delete_hover="#e0c4bf", delete_fg="#d20f39", warn_bg="#ece6d4", warn_hover="#e0dabf", diff --git a/diffui/themes/theme.py b/diffui/themes/theme.py index c860fdc..2df29bd 100644 --- a/diffui/themes/theme.py +++ b/diffui/themes/theme.py @@ -24,8 +24,6 @@ class Theme: comment_bg: str comment_header_bg: str comment_accent: str - delete_bg: str - delete_hover: str delete_fg: str warn_bg: str warn_hover: str diff --git a/diffui/tui/client.py b/diffui/tui/client.py index a74d12a..9dd30c2 100644 --- a/diffui/tui/client.py +++ b/diffui/tui/client.py @@ -12,7 +12,7 @@ import httpx import uvicorn -from diffui.cli import _find_free_port +from diffui.cli import find_free_port from diffui.diff import resolve_line_num from diffui.server.app import create_app @@ -37,10 +37,10 @@ def _json(response: httpx.Response): class LocalServer: - """The FastAPI app on a free port, served from a background thread so the lifespan (watcher, host status) runs.""" + """Served from a background thread so the lifespan (watcher, host status) runs.""" def __init__(self, repos: list[Path], active_index: int = 0) -> None: - self.port = _find_free_port() + self.port = find_free_port() config = uvicorn.Config( create_app(repos, active_index=active_index), host="127.0.0.1", @@ -117,7 +117,7 @@ async def toggle_reviewed(self, path: str) -> bool: return _json(await self._post(f"/api/reviewed/{quote(path)}"))["reviewed"] async def add_comment(self, path: str, line: dict, text: str, covered: list[dict] | None = None) -> None: - """`covered` is the range's diff lines, starting at `line`. It sends the fields the web UI sends for a multi-line selection.""" + """`covered` is the range's diff lines, starting at `line`.""" body = { "file_path": path, "line_index": line["index"], diff --git a/diffui/tui/find.py b/diffui/tui/find.py index 328b768..6247ede 100644 --- a/diffui/tui/find.py +++ b/diffui/tui/find.py @@ -28,13 +28,12 @@ def search(self, rows: list[Row], query: str, near: int = 0, keep: tuple | None return pattern = re.compile(re.escape(query), re.IGNORECASE) for i, row in enumerate(rows): - # A context line shows on both sides of a split row but counts once. - mirrored = row.kind == "line" and row.old is not None and row.old is row.new - for side, line in [("new", row.new)] if mirrored else row.sides(): + for side, line in row.sides(): for m in pattern.finditer(code_of(line)): self._cells.setdefault((i, side), []).append((m.start(), m.end(), len(self.matches))) self.matches.append((i, side, m.start(), m.end())) - if mirrored and (i, "new") in self._cells: + # A context line shows on both sides of a split row but counts once. + if row.mirrored() and (i, "new") in self._cells: self._cells[(i, "old")] = self._cells[(i, "new")] if keep is not None: self.current = next((n for n in range(len(self.matches)) if self.identity(rows, n) == keep), -1) @@ -73,7 +72,7 @@ class _FindInput(Input): class FindBar(Horizontal): - """The find-in-file bar under the diff. enter and shift+enter step through the matches, esc closes it.""" + """The find-in-file bar under the diff.""" def compose(self) -> ComposeResult: yield Static("Find", id="find-label") diff --git a/diffui/tui/rows.py b/diffui/tui/rows.py index 55ad337..b16c9a9 100644 --- a/diffui/tui/rows.py +++ b/diffui/tui/rows.py @@ -38,20 +38,26 @@ def key(self) -> tuple: return ("thread", self.thread.get("id")) return (self.kind, self.text) + def mirrored(self) -> bool: + """A split row's context line, shown on both sides.""" + return self.old is not None and self.old is self.new + def lines(self) -> list[dict]: """The distinct diff lines the row shows.""" if self.old is None and self.new is None: return [self.line] if self.line else [] - return [self.old] if self.old is self.new else [ln for ln in (self.old, self.new) if ln] + return [self.old] if self.mirrored() else [ln for ln in (self.old, self.new) if ln] def sides(self) -> list[tuple[str, dict]]: - """(side, line) pairs for find: unified rows have one side, split rows up to two.""" + """(side, line) pairs for find: unified rows have one side, split rows up to two, a mirrored line its new side.""" if self.old is None and self.new is None: return [("u", self.line)] if self.line else [] + if self.mirrored(): + return [("new", self.new)] return [(side, ln) for side, ln in (("old", self.old), ("new", self.new)) if ln] def paired(self) -> bool: - return self.old is not None and self.new is not None and self.old is not self.new + return self.old is not None and self.new is not None and not self.mirrored() def tokens_of(self, line: dict, lexer, syntax: dict) -> Tokens: cached = self.tokens.get(line["index"]) diff --git a/diffui/tui/search.py b/diffui/tui/search.py index 25cb430..b97a2b4 100644 --- a/diffui/tui/search.py +++ b/diffui/tui/search.py @@ -23,7 +23,7 @@ class SearchScreen(ModalScreen[Target | None]): - """Search the files in the current view, or the whole repo, by name and content. Enter opens the file, at the line for a content hit.""" + """Search file names and content.""" BINDINGS: ClassVar[list[BindingType]] = [ Binding("escape", "dismiss_search", show=False), diff --git a/diffui/tui/style.py b/diffui/tui/style.py index b008577..9be11f0 100644 --- a/diffui/tui/style.py +++ b/diffui/tui/style.py @@ -15,8 +15,8 @@ @lru_cache(maxsize=512) -def style(fg: str | None, bg: str, bold: bool = False, dim: bool = False) -> Style: - return Style(color=Color.parse(fg) if fg else None, bgcolor=Color.parse(bg), bold=bold or None, dim=dim or None) +def style(fg: str | None, bg: str, bold: bool = False) -> Style: + return Style(color=Color.parse(fg) if fg else None, bgcolor=Color.parse(bg), bold=bold or None) def span(first: str, last: str) -> str: diff --git a/pyproject.toml b/pyproject.toml index e30494e..fcca2ea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "diffui" -version = "0.11.0" +version = "0.11.1" description = "UI for reviewing AI agent diffs" requires-python = ">=3.10" dependencies = [ @@ -50,6 +50,3 @@ ignore = [ "SIM108", # ternary instead of if/else (readability preference) "SIM105", # contextlib.suppress — try/except/pass is clearer for single-line cases ] - -[tool.ruff.lint.isort] -known-first-party = ["diffui"] diff --git a/tests/test_agent_terminal.py b/tests/test_agent_terminal.py index 1014ace..9f1eff4 100644 --- a/tests/test_agent_terminal.py +++ b/tests/test_agent_terminal.py @@ -16,7 +16,6 @@ import diffui.server.routes_agent_terminal as rat from diffui.server.routes_agent_terminal import ( _BUFFER_MAX_BYTES, - _flush_buffer, _is_running, _kill_agent, _read_pty, @@ -45,23 +44,13 @@ def _reset_module_state(): rat._pending_start = saved_pending -class TestFlushBuffer: - def test_empty_buffer(self): - rat._output_buffer.clear() - assert _flush_buffer() == b"" - - def test_small_buffer_joined(self): - rat._output_buffer.clear() - rat._output_buffer.append(b"hello ") - rat._output_buffer.append(b"world") - assert _flush_buffer() == b"hello world" - +class TestAppendOutput: def test_buffer_stays_under_max_as_output_arrives(self): rat._output_buffer.clear() chunk = b"x" * 1000 for _ in range(_BUFFER_MAX_BYTES // len(chunk) + 10): rat._append_output(chunk) - result = _flush_buffer() + result = b"".join(rat._output_buffer) assert len(result) <= _BUFFER_MAX_BYTES assert result.endswith(chunk) diff --git a/tests/test_events.py b/tests/test_events.py index e63de8f..2f78e77 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -163,13 +163,18 @@ def test_broadcast_full_queue_skipped(self): _clients.discard(q) +async def _broadcast_on_loop(events): + _broadcast(events) + await asyncio.sleep(0) + + class TestWsBroadcast: def test_broadcast_delivers_to_ws_clients(self): ws = AsyncMock() _ws_clients.add(ws) try: - _broadcast(["files_changed"]) + asyncio.run(_broadcast_on_loop(["files_changed"])) ws.send_text.assert_called_once() import json @@ -187,7 +192,7 @@ def test_broadcast_sends_to_both_sse_and_ws(self): ws = AsyncMock() _ws_clients.add(ws) try: - _broadcast(["git_changed"]) + asyncio.run(_broadcast_on_loop(["git_changed"])) assert not q.empty() ws.send_text.assert_called_once() finally: @@ -200,7 +205,7 @@ def test_git_changed_reloads_and_clears_cache(self): with ( patch("diffui.server.state.app_state.reload_repo_state") as reload, - patch("diffui.server.routes_diff.clear_diff_cache") as clear_cache, + patch("diffui.server.events.clear_diff_cache") as clear_cache, ): result = _apply_state_updates(["git_changed"]) @@ -212,7 +217,7 @@ def test_git_changed_does_not_duplicate_files_changed(self): with ( patch("diffui.server.state.app_state.reload_repo_state"), - patch("diffui.server.routes_diff.clear_diff_cache"), + patch("diffui.server.events.clear_diff_cache"), ): result = _apply_state_updates(["git_changed", "files_changed"]) @@ -224,8 +229,8 @@ def test_files_changed_refreshes_numstat_and_working_files(self): prev_working = app_state.working_files try: with ( - patch("diffui.git_utils.get_diff_numstat", return_value={"a.py": (1, 2)}) as numstat, - patch("diffui.git_utils.get_working_changed_files", return_value=["a.py"]) as working, + patch("diffui.server.events.get_diff_numstat", return_value={"a.py": (1, 2)}) as numstat, + patch("diffui.server.events.get_working_changed_files", return_value=["a.py"]) as working, ): result = _apply_state_updates(["files_changed"]) @@ -242,9 +247,9 @@ def test_files_changed_skipped_when_git_changed_present(self): with ( patch("diffui.server.state.app_state.reload_repo_state"), - patch("diffui.server.routes_diff.clear_diff_cache"), - patch("diffui.git_utils.get_diff_numstat") as numstat, - patch("diffui.git_utils.get_working_changed_files") as working, + patch("diffui.server.events.clear_diff_cache"), + patch("diffui.server.events.get_diff_numstat") as numstat, + patch("diffui.server.events.get_working_changed_files") as working, ): _apply_state_updates(["git_changed"]) @@ -268,7 +273,7 @@ def test_git_and_comments_changed_reload_comments_once(self): with ( patch("diffui.server.state.app_state.reload_repo_state") as reload, - patch("diffui.server.routes_diff.clear_diff_cache") as clear_cache, + patch("diffui.server.events.clear_diff_cache") as clear_cache, patch("diffui.server.events.load_comments") as load, ): result = _apply_state_updates(["git_changed", "comments_changed"]) @@ -290,7 +295,7 @@ def test_git_changed_announces_reloaded_comments(self): "diffui.server.state.app_state.reload_repo_state", side_effect=lambda: setattr(app_state, "comments", reloaded), ), - patch("diffui.server.routes_diff.clear_diff_cache"), + patch("diffui.server.events.clear_diff_cache"), patch("diffui.server.events._invalidate_stale_reviews"), patch("diffui.server.events.load_comments", return_value=reloaded), ): @@ -303,7 +308,7 @@ def test_git_changed_announces_reloaded_comments(self): def test_git_changed_stays_quiet_when_comments_unchanged(self): with ( patch("diffui.server.state.app_state.reload_repo_state"), - patch("diffui.server.routes_diff.clear_diff_cache"), + patch("diffui.server.events.clear_diff_cache"), patch("diffui.server.events._invalidate_stale_reviews"), ): result = _apply_state_updates(["git_changed"]) @@ -319,7 +324,7 @@ def test_branch_switch_restarts_watcher(self): "diffui.server.state.app_state.reload_repo_state", side_effect=lambda: setattr(app_state, "branch_name", "new-branch"), ), - patch("diffui.server.routes_diff.clear_diff_cache"), + patch("diffui.server.events.clear_diff_cache"), patch("diffui.server.events._invalidate_stale_reviews"), patch("diffui.server.events.restart_watcher") as restart, ): @@ -332,7 +337,7 @@ def test_branch_switch_restarts_watcher(self): def test_same_branch_leaves_watcher_alone(self): with ( patch("diffui.server.state.app_state.reload_repo_state"), - patch("diffui.server.routes_diff.clear_diff_cache"), + patch("diffui.server.events.clear_diff_cache"), patch("diffui.server.events._invalidate_stale_reviews"), patch("diffui.server.events.restart_watcher") as restart, ): @@ -349,9 +354,11 @@ def test_files_changed_unreviews_modified_files(self): try: app_state.reviewed = {"changed.py": 1000.0, "untouched.py": 2000.0} with ( - patch("diffui.git_utils.get_diff_numstat", return_value={}), - patch("diffui.git_utils.get_working_changed_files", return_value=[]), - patch("diffui.git_utils.get_file_mtime", side_effect=lambda p: 1500.0 if p == "changed.py" else 2000.0), + patch("diffui.server.events.get_diff_numstat", return_value={}), + patch("diffui.server.events.get_working_changed_files", return_value=[]), + patch( + "diffui.server.events.get_file_mtime", side_effect=lambda p: 1500.0 if p == "changed.py" else 2000.0 + ), ): _apply_state_updates(["files_changed"]) @@ -368,9 +375,9 @@ def test_git_changed_also_unreviews_modified_files(self): app_state.reviewed = {"changed.py": 1000.0} with ( patch("diffui.server.state.app_state.reload_repo_state"), - patch("diffui.server.routes_diff.clear_diff_cache"), - patch("diffui.git_utils.get_file_mtime", return_value=2000.0), - patch("diffui.git_utils.save_reviewed"), + patch("diffui.server.events.clear_diff_cache"), + patch("diffui.server.events.get_file_mtime", return_value=2000.0), + patch("diffui.server.events.save_reviewed"), ): _apply_state_updates(["git_changed"]) @@ -385,9 +392,9 @@ def test_files_changed_keeps_reviewed_when_mtime_matches(self): try: app_state.reviewed = {"stable.py": 3000.0} with ( - patch("diffui.git_utils.get_diff_numstat", return_value={}), - patch("diffui.git_utils.get_working_changed_files", return_value=[]), - patch("diffui.git_utils.get_file_mtime", return_value=3000.0), + patch("diffui.server.events.get_diff_numstat", return_value={}), + patch("diffui.server.events.get_working_changed_files", return_value=[]), + patch("diffui.server.events.get_file_mtime", return_value=3000.0), ): _apply_state_updates(["files_changed"]) diff --git a/tests/test_highlight.py b/tests/test_highlight.py index 74ce732..f44d9c2 100644 --- a/tests/test_highlight.py +++ b/tests/test_highlight.py @@ -59,14 +59,6 @@ def test_handles_html_entities(self): result = _apply_word_highlights(html, [(0, 5)], "
") assert "" in result - def test_malformed_tag_no_crash(self): - result = _apply_word_highlights("