From 12b344d8803b28f28ad85caccdbd0d5444ef6c6d Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Sun, 7 Jun 2026 21:39:24 +0700 Subject: [PATCH 01/59] docs: keyword highlighting design spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Render-layer overlay approach via xterm fork — scans only visible lines at paint time, no data mutation, recordings unaffected. --- .../2026-06-07-keyword-highlighting-design.md | 241 ++++++++++++++++++ 1 file changed, 241 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-07-keyword-highlighting-design.md diff --git a/docs/superpowers/specs/2026-06-07-keyword-highlighting-design.md b/docs/superpowers/specs/2026-06-07-keyword-highlighting-design.md new file mode 100644 index 00000000..2e7816b1 --- /dev/null +++ b/docs/superpowers/specs/2026-06-07-keyword-highlighting-design.md @@ -0,0 +1,241 @@ +# Keyword Highlighting — Design Spec + +**Date:** 2026-06-07 +**Status:** Approved +**Scope:** Global keyword highlight rules that tint matching text in all terminal sessions (SSH + local shell) + +--- + +## Overview + +User-defined regex/literal rules that highlight matching text in terminal output with a configurable foreground color, background color, or both. Ships with verbose defaults (error/warning/success/done/ok/debug/info). Managed in Settings → Terminal and togglable per-rule in the workspace side panel. + +Implementation is a **render-layer overlay** inside the xterm fork — data is never modified, recordings are unaffected. + +--- + +## Data Model + +### App layer — `app/lib/models/keyword_highlight_rule.dart` + +```dart +class AppKeywordHighlightRule { + final String id; // UUID — stable key for reorder/delete + final String label; // display name ("Error", "Warning" …) + final String pattern; // raw string entered by user + final bool isRegex; // false → pattern is treated as literal (auto-escaped) + final bool caseSensitive; // default false + final bool enabled; + final Color? foreground; // null = don't override text color + final Color? background; // null = don't tint background +} +``` + +- Stored as JSON array in `SharedPreferences['keywordHighlightRules']`. +- Color stored as int (`Color.value`). +- `toXtermRule()` compiles the regex and returns an `xterm.KeywordHighlightRule`; if the pattern is invalid it returns null and the rule is silently skipped. +- `toJson()` / `fromJson()` for persistence. +- Maximum 20 rules enforced in the settings UI (performance cap). + +### xterm layer — `packages/xterm/lib/src/ui/keyword_highlight.dart` + +```dart +class KeywordHighlightRule { + final RegExp pattern; // pre-compiled, ready for allMatches() + final Color? foreground; + final Color? background; +} +``` + +The xterm package has no knowledge of labels, ids, or enabled state — those are filtered at the app layer before passing compiled rules into `TerminalView`. + +### Default rules + +All rules are case-insensitive (`caseSensitive: false`). + +| Label | Pattern | isRegex | Foreground | Background | +|---------|---------------|---------|----------------------|------------------------| +| Error | `error` | false | — | `Colors.red.shade700` | +| Fail | `fail` | false | — | `Colors.red.shade700` | +| Warning | `warning` | false | — | `Colors.orange.shade700` | +| Warn | `warn` | false | — | `Colors.orange.shade700` | +| Success | `success` | false | `Colors.green.shade300` | — | +| Done | `done` | false | `Colors.green.shade300` | — | +| OK | `\bok\b` | true | `Colors.green.shade300` | — | +| Debug | `debug` | false | `Colors.grey.shade500` | — | +| Info | `info` | false | `Colors.cyan.shade300` | — | + +`\bok\b` uses word boundaries to avoid matching "working", "broken", etc. + +--- + +## Architecture & Data Flow + +``` +SettingsProvider + .keywordHighlightingEnabled (bool, default true) + .keywordHighlightRules (List, default kDefaultKeywordHighlightRules) + │ + │ context.select — rebuild only when rules change + ▼ +_TerminalWidgetState.build() [app/lib/widgets/terminal_view.dart] + filters enabled rules + calls .toXtermRule() on each + → List + │ + ▼ +TerminalView(keywordRules: compiledRules) [packages/xterm/lib/src/terminal_view.dart] + │ + ▼ +RenderTerminal._keywordRules + paint() → _paintKeywordHighlights() + iterates effectFirstLine..effectLastLine (visible viewport only) + per line: getText() → regex.allMatches() → bg rect + fg re-render +``` + +**Paint order inside `_paint()`:** + +1. `paintLine()` × visible lines — ANSI-colored text +2. `_paintKeywordHighlights()` — background rects, then foreground re-renders *(new)* +3. Cursor +4. `_paintHighlights()` — search overlays (visually on top of keyword highlights) +5. `_paintSelection()` + +Search highlights win over keyword highlights by paint order — intended, since search is a user-initiated action. + +**Local shell sessions:** Automatically supported. Feature lives in the xterm render layer, independent of `SshService` and `HookBus`. `LocalShellService` writes directly to `Terminal.write()` — same xterm object, same render path. + +**Recordings:** Unaffected. `_recording?.writeOutput()` receives the original text before any render transform. ✅ + +--- + +## xterm Fork Changes + +### 1. `packages/xterm/lib/src/ui/keyword_highlight.dart` *(new)* + +Defines `KeywordHighlightRule` with pre-compiled `RegExp`, nullable `foreground`, nullable `background`. Exported from `xterm.dart`. + +### 2. `packages/xterm/lib/src/ui/painter.dart` + +Add `paintKeywordForeground(canvas, offset, line, startCol, endCol, Color)`: + +- Iterates `startCol..endCol` cells on the given `BufferLine`. +- Re-renders each character with the override foreground color, bypassing `_paragraphCache` (cache keys include original ANSI color — can't reuse). +- Handles wide characters (charWidth == 2): skips the phantom second cell to match `paintLine()` behavior. +- Guards `endCol` against `line.length` to prevent out-of-bounds. + +### 3. `packages/xterm/lib/src/ui/render.dart` + +Add to `RenderTerminal`: + +```dart +List _keywordRules = const []; +set keywordRules(List value) { + _keywordRules = value; + markNeedsPaint(); +} +``` + +Add `_paintKeywordHighlights(canvas, offset, firstLine, lastLine)`: + +``` +for each visible line i: + lineText = lines[i].getText() + for each rule: + for each match in rule.pattern.allMatches(lineText): + if rule.background != null → _paintSegment(segment, rule.background) + if rule.foreground != null → painter.paintKeywordForeground(…, m.start, m.end, rule.foreground) +``` + +Called in `_paint()` after the line-painting loop, before the cursor. + +### 4. `packages/xterm/lib/src/terminal_view.dart` + +Thread `List keywordRules = const []` from `TerminalView` → `_TerminalView` → `RenderTerminal.updateRenderObject()`. + +--- + +## App-side Changes + +### `app/lib/providers/settings_provider.dart` + +Add: +- `bool keywordHighlightingEnabled = true` +- `List keywordHighlightRules = kDefaultKeywordHighlightRules` + +Load from `SharedPreferences` in `load()`; persist in `save()` / `update()`. If key absent → use defaults (first-run experience shows verbose defaults immediately). + +### `app/lib/widgets/terminal_view.dart` + +In `_TerminalWidgetState.build()`: + +```dart +final rules = context.select>( + (s) => s.keywordHighlightingEnabled + ? s.keywordHighlightRules + .where((r) => r.enabled) + .map((r) => r.toXtermRule()) + .whereType() + .toList() + : const [], +); +``` + +Pass `keywordRules: rules` into `TerminalView`. + +### `app/lib/widgets/settings_screen.dart` + +New "KEYWORD HIGHLIGHTING" section in the Terminal tab, after `TerminalAppearanceControls`: + +- Master `SwitchListTile`: Enable keyword highlighting +- Rule list: each row shows color swatch(es) + label/pattern + per-rule enabled toggle + delete icon +- "Add rule" button (disabled when rule count ≥ 20) +- Add/Edit dialog fields: label, pattern, regex toggle, case-sensitive toggle, foreground color picker (nullable), background color picker (nullable) +- Color picker: simple grid of ~16 DevOps-friendly presets (no full HSV for V1) + a "None" option + +### `app/lib/widgets/terminal_config_panel.dart` + +Compact "KEYWORD HIGHLIGHTING" subsection after `TerminalAppearanceControls`: + +- Master enable toggle +- Per-rule rows: label + color swatch(es) + enabled toggle (no add/edit/delete here) +- "Open Settings →" link that navigates to Settings → Terminal + +--- + +## Edge Cases & Error Handling + +| Case | Handling | +|------|----------| +| Invalid regex pattern | `toXtermRule()` returns null; rule silently skipped. Settings dialog validates real-time and shows "Invalid regex" inline. | +| Performance (busy log stream) | Only visible lines scanned per paint (~40–60 lines × ≤20 rules). Acceptable overhead. | +| ANSI codes in getText() | `BufferLine.getText()` returns plain text — ANSI already stripped. Column offsets from regex match map directly to cell columns. | +| Wide characters (CJK/emoji) | `paintKeywordForeground()` mirrors `paintLine()`'s `charWidth == 2` skip logic. | +| Scrollback pruning | No `CellAnchor` objects used. Highlights are computed fresh from the live buffer on every paint frame; pruned lines disappear naturally. | +| Alt-screen (vim, htop) | `_terminal.buffer.lines` points to the active buffer. Keyword rules apply to alt-screen content — minor visual noise acceptable for V1. | +| Rule count limit | Max 20 rules enforced in settings UI. `_paintKeywordHighlights()` requires no guard — the cap is at the input boundary. | + +--- + +## Testing + +- **Unit — `AppKeywordHighlightRule`**: `toXtermRule()` compiles regex correctly; invalid pattern returns null; `toJson()`/`fromJson()` roundtrip; `kDefaultKeywordHighlightRules` all compile without error. +- **Unit — `paintKeywordForeground()`**: Does not throw for empty line, `endCol > line.length`, wide-char cells. +- **Widget — `_TerminalWidgetState`**: When `keywordHighlightingEnabled = false`, an empty `keywordRules` list is passed to `TerminalView`. When a rule is disabled, it is excluded from the compiled list. + +--- + +## Files Changed + +| File | Change | +|------|--------| +| `packages/xterm/lib/src/ui/keyword_highlight.dart` | New | +| `packages/xterm/lib/src/ui/painter.dart` | +`paintKeywordForeground()` | +| `packages/xterm/lib/src/ui/render.dart` | +`keywordRules` property, +`_paintKeywordHighlights()` | +| `packages/xterm/lib/src/terminal_view.dart` | Thread `keywordRules` param | +| `packages/xterm/xterm.dart` | Export `KeywordHighlightRule` | +| `app/lib/models/keyword_highlight_rule.dart` | New — app model + defaults | +| `app/lib/providers/settings_provider.dart` | +2 fields + persist | +| `app/lib/widgets/terminal_view.dart` | Wire compiled rules into `TerminalView` | +| `app/lib/widgets/settings_screen.dart` | New keyword highlighting section | +| `app/lib/widgets/terminal_config_panel.dart` | Compact toggle section | From df1a1ec5e6f1414f39bcd02beb21944f0fafaa11 Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Sun, 7 Jun 2026 22:21:51 +0700 Subject: [PATCH 02/59] feat(xterm): KeywordHighlightRule type for render-layer keyword highlighting --- packages/xterm/lib/src/ui/keyword_highlight.dart | 13 +++++++++++++ packages/xterm/lib/ui.dart | 1 + 2 files changed, 14 insertions(+) create mode 100644 packages/xterm/lib/src/ui/keyword_highlight.dart diff --git a/packages/xterm/lib/src/ui/keyword_highlight.dart b/packages/xterm/lib/src/ui/keyword_highlight.dart new file mode 100644 index 00000000..5568be8e --- /dev/null +++ b/packages/xterm/lib/src/ui/keyword_highlight.dart @@ -0,0 +1,13 @@ +import 'dart:ui'; + +class KeywordHighlightRule { + final RegExp pattern; + final Color? foreground; + final Color? background; + + const KeywordHighlightRule({ + required this.pattern, + this.foreground, + this.background, + }); +} diff --git a/packages/xterm/lib/ui.dart b/packages/xterm/lib/ui.dart index 72cbf52e..db0cddbc 100644 --- a/packages/xterm/lib/ui.dart +++ b/packages/xterm/lib/ui.dart @@ -2,6 +2,7 @@ export 'src/terminal_view.dart'; export 'src/ui/clipboard_ops.dart'; export 'src/ui/controller.dart'; export 'src/ui/cursor_type.dart'; +export 'src/ui/keyboard_highlight.dart'; export 'src/ui/keyboard_visibility.dart'; export 'src/ui/pointer_input.dart'; export 'src/ui/selection_mode.dart'; From 0eae0dd27057bed547909a73663ef3556b7a4145 Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Sun, 7 Jun 2026 22:24:43 +0700 Subject: [PATCH 03/59] =?UTF-8?q?fix(xterm):=20correct=20export=20filename?= =?UTF-8?q?=20keyboard=E2=86=92keyword=20in=20ui.dart;=20add=20implementat?= =?UTF-8?q?ion=20plan?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plans/2026-06-07-keyword-highlighting.md | 1764 +++++++++++++++++ packages/xterm/lib/ui.dart | 2 +- 2 files changed, 1765 insertions(+), 1 deletion(-) create mode 100644 docs/superpowers/plans/2026-06-07-keyword-highlighting.md diff --git a/docs/superpowers/plans/2026-06-07-keyword-highlighting.md b/docs/superpowers/plans/2026-06-07-keyword-highlighting.md new file mode 100644 index 00000000..4283b9ca --- /dev/null +++ b/docs/superpowers/plans/2026-06-07-keyword-highlighting.md @@ -0,0 +1,1764 @@ +# Keyword Highlighting Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add user-defined regex/literal rules that tint matching text in terminal output with configurable foreground and/or background colors, with verbose defaults and global settings UI. + +**Architecture:** Render-layer overlay in the xterm fork — `RenderTerminal` scans only visible lines at paint time using `BufferLine.getText()` + regex, then draws colored rects (background) and re-renders text cells (foreground) on top of normal ANSI output. No data mutation, recordings unaffected. + +**Tech Stack:** Dart/Flutter, xterm fork (`packages/xterm`), `shared_preferences`, `uuid`, `provider`. + +--- + +## File Map + +| File | Action | +|------|--------| +| `packages/xterm/lib/src/ui/keyword_highlight.dart` | Create — xterm-layer rule type | +| `packages/xterm/lib/ui.dart` | Modify — add export | +| `packages/xterm/lib/src/ui/painter.dart` | Modify — add `paintKeywordForeground()` | +| `packages/xterm/lib/src/ui/render.dart` | Modify — add `_keywordRules` + `_paintKeywordHighlights()` | +| `packages/xterm/lib/src/terminal_view.dart` | Modify — thread `keywordRules` param | +| `app/lib/models/keyword_highlight_rule.dart` | Create — app-layer model + defaults | +| `app/test/models/keyword_highlight_rule_test.dart` | Create — unit tests | +| `app/lib/providers/settings_provider.dart` | Modify — add 2 fields + persist | +| `app/test/settings_provider_test.dart` | Modify — add persistence tests | +| `app/lib/widgets/terminal_view.dart` | Modify — wire compiled rules to TerminalView | +| `app/lib/widgets/keyword_highlight_settings.dart` | Create — settings UI (rule list + add/edit dialog) | +| `app/lib/widgets/settings_screen.dart` | Modify — add keyword highlighting section | +| `app/lib/widgets/terminal_config_panel.dart` | Modify — add compact toggle section | + +--- + +### Task 1: xterm KeywordHighlightRule type + +**Files:** +- Create: `packages/xterm/lib/src/ui/keyword_highlight.dart` +- Modify: `packages/xterm/lib/ui.dart` + +- [ ] **Step 1: Create the xterm-layer rule type** + +```dart +// packages/xterm/lib/src/ui/keyword_highlight.dart +import 'dart:ui'; + +class KeywordHighlightRule { + final RegExp pattern; + final Color? foreground; + final Color? background; + + const KeywordHighlightRule({ + required this.pattern, + this.foreground, + this.background, + }); +} +``` + +- [ ] **Step 2: Export from `packages/xterm/lib/ui.dart`** + +Add this line to `packages/xterm/lib/ui.dart`: + +```dart +export 'src/ui/keyword_highlight.dart'; +``` + +The final file should be: + +```dart +export 'src/terminal_view.dart'; +export 'src/ui/clipboard_ops.dart'; +export 'src/ui/controller.dart'; +export 'src/ui/cursor_type.dart'; +export 'src/ui/keyboard_highlight.dart'; +export 'src/ui/keyboard_visibility.dart'; +export 'src/ui/pointer_input.dart'; +export 'src/ui/selection_mode.dart'; +export 'src/ui/shortcut/shortcuts.dart'; +export 'src/ui/terminal_text_style.dart'; +export 'src/ui/terminal_theme.dart'; +export 'src/ui/themes.dart'; +``` + +- [ ] **Step 3: Verify the package compiles** + +```bash +cd packages/xterm && flutter analyze +``` + +Expected: no errors. + +- [ ] **Step 4: Commit** + +```bash +git add packages/xterm/lib/src/ui/keyword_highlight.dart packages/xterm/lib/ui.dart +git commit -m "feat(xterm): KeywordHighlightRule type for render-layer keyword highlighting" +``` + +--- + +### Task 2: painter.paintKeywordForeground() + +**Files:** +- Modify: `packages/xterm/lib/src/ui/painter.dart` + +Add `paintKeywordForeground()` after the `paintHighlight()` method (around line 138). This method re-renders specific cells with an override foreground color, bypassing the paragraph cache (which is keyed to the original ANSI color). + +- [ ] **Step 1: Add `paintKeywordForeground()` to `TerminalPainter`** + +Insert after `paintHighlight()`: + +```dart +/// Re-renders cells in [startCol]..[endCol] on [line] with [fgColor], +/// bypassing the paragraph cache. Uses the same coordinate system as +/// [paintHighlight]: [lineOffset] is Offset(0, lineY) without canvas offset. +void paintKeywordForeground( + Canvas canvas, + Offset lineOffset, + BufferLine line, + int startCol, + int endCol, + Color fgColor, +) { + final cellData = CellData.empty(); + final cellWidth = _cellSize.width; + + for (var i = startCol; i < endCol && i < line.length; i++) { + line.getCellData(i, cellData); + final charCode = cellData.content & CellContent.codepointMask; + final charWidth = cellData.content >> CellContent.widthShift; + + if (charCode != 0) { + final style = _textStyle.toTextStyle(color: fgColor); + final builder = ParagraphBuilder(style.getParagraphStyle()) + ..pushStyle(style.getTextStyle(textScaler: _textScaler)) + ..addText(String.fromCharCode(charCode)); + final para = builder.build() + ..layout(ParagraphConstraints(width: cellWidth * 2)); + canvas.drawParagraph(para, lineOffset.translate(i * cellWidth, 0)); + para.dispose(); + } + + if (charWidth == 2) i++; // skip phantom cell of double-width char + } +} +``` + +- [ ] **Step 2: Verify the package compiles** + +```bash +cd packages/xterm && flutter analyze +``` + +Expected: no errors. + +- [ ] **Step 3: Commit** + +```bash +git add packages/xterm/lib/src/ui/painter.dart +git commit -m "feat(xterm): paintKeywordForeground — re-render cells with override color" +``` + +--- + +### Task 3: RenderTerminal keyword rules + _paintKeywordHighlights() + +**Files:** +- Modify: `packages/xterm/lib/src/ui/render.dart` + +- [ ] **Step 1: Add import for `keyword_highlight.dart`** + +In `packages/xterm/lib/src/ui/render.dart`, add this import after the existing ui imports: + +```dart +import 'package:xterm/src/ui/keyword_highlight.dart'; +``` + +- [ ] **Step 2: Add `_keywordRules` property to `RenderTerminal`** + +Add after `final TerminalPainter _painter;` (around line 152): + +```dart +List _keywordRules = const []; +set keywordRules(List value) { + if (_keywordRules == value) return; + _keywordRules = value; + markNeedsPaint(); +} +``` + +- [ ] **Step 3: Add `_paintKeywordHighlights()` method** + +Add after `_paintSelection()` (before or after `_paintSegment()`, at the end of the class): + +```dart +void _paintKeywordHighlights(Canvas canvas, int firstLine, int lastLine) { + if (_keywordRules.isEmpty) return; + final lines = _terminal.buffer.lines; + final charHeight = _painter.cellSize.height; + + for (var i = firstLine; i <= lastLine; i++) { + if (i >= lines.length) break; + final lineText = lines[i].getText(); + final lineY = i * charHeight + _lineOffset; + + for (final rule in _keywordRules) { + for (final m in rule.pattern.allMatches(lineText)) { + if (m.start == m.end) continue; + + if (rule.background != null) { + _painter.paintHighlight( + canvas, + Offset(m.start * _painter.cellSize.width, lineY), + m.end - m.start, + rule.background!, + ); + } + + if (rule.foreground != null) { + _painter.paintKeywordForeground( + canvas, + Offset(0, lineY), + lines[i], + m.start, + m.end, + rule.foreground!, + ); + } + } + } + } +} +``` + +- [ ] **Step 4: Call `_paintKeywordHighlights()` in `_paint()`** + +In `_paint()`, after the for-loop that paints lines and before the cursor block, insert: + +```dart +_paintKeywordHighlights(canvas, effectFirstLine, effectLastLine); +``` + +The full `_paint()` structure becomes: + +```dart +// 1. paint lines +for (var i = effectFirstLine; i <= effectLastLine; i++) { + _painter.paintLine(...); +} + +// 2. keyword highlights (NEW — before cursor so cursor stays on top) +_paintKeywordHighlights(canvas, effectFirstLine, effectLastLine); + +// 3. cursor +if (_terminal.buffer.absoluteCursorY >= effectFirstLine && ...) { ... } + +// 4. search highlights (on top of keyword highlights) +_paintHighlights(canvas, _controller.highlights, effectFirstLine, effectLastLine); + +// 5. selection +if (_controller.selection != null) { ... } +``` + +- [ ] **Step 5: Verify the package compiles** + +```bash +cd packages/xterm && flutter analyze +``` + +Expected: no errors. + +- [ ] **Step 6: Commit** + +```bash +git add packages/xterm/lib/src/ui/render.dart +git commit -m "feat(xterm): render keyword highlights on visible lines at paint time" +``` + +--- + +### Task 4: Thread keywordRules through xterm TerminalView + +**Files:** +- Modify: `packages/xterm/lib/src/terminal_view.dart` + +- [ ] **Step 1: Add `keywordRules` field to `TerminalView`** + +In the `TerminalView` class, add after `simulateScroll`: + +```dart +/// Rules for keyword highlighting. Applied at paint time — only visible +/// lines are scanned. Defaults to an empty list (no highlighting). +final List keywordRules; +``` + +Add `this.keywordRules = const [],` to the constructor parameter list (after `simulateScroll`). + +- [ ] **Step 2: Add `keywordRules` to `_TerminalView`** + +In `_TerminalView`: + +Add field: +```dart +final List keywordRules; +``` + +Add to constructor `const _TerminalView({...})`: +```dart +required this.keywordRules, +``` + +- [ ] **Step 3: Wire into `createRenderObject` and `updateRenderObject`** + +In `_TerminalView.createRenderObject()`: +```dart +return RenderTerminal( + // ... existing params ... + keywordRules: keywordRules, // ADD THIS +); +``` + +Wait — `RenderTerminal`'s constructor doesn't have `keywordRules` yet (it's set via the setter). After creating the object, set it: + +```dart +@override +RenderTerminal createRenderObject(BuildContext context) { + final renderObject = RenderTerminal( + terminal: terminal, + controller: controller, + offset: offset, + padding: padding, + autoResize: autoResize, + textStyle: textStyle, + textScaler: textScaler, + theme: theme, + focusNode: focusNode, + cursorType: cursorType, + alwaysShowCursor: alwaysShowCursor, + onEditableRect: onEditableRect, + composingText: composingText, + ); + renderObject.keywordRules = keywordRules; + return renderObject; +} +``` + +In `_TerminalView.updateRenderObject()`: +```dart +@override +void updateRenderObject(BuildContext context, RenderTerminal renderObject) { + renderObject + ..terminal = terminal + ..controller = controller + ..offset = offset + ..padding = padding + ..autoResize = autoResize + ..textStyle = textStyle + ..textScaler = textScaler + ..theme = theme + ..focusNode = focusNode + ..cursorType = cursorType + ..alwaysShowCursor = alwaysShowCursor + ..onEditableRect = onEditableRect + ..composingText = composingText + ..keywordRules = keywordRules; // ADD THIS +} +``` + +- [ ] **Step 4: Thread from `TerminalViewState.build()` to `_TerminalView`** + +In `TerminalViewState.build()`, find where `_TerminalView` is constructed and add `keywordRules: widget.keywordRules`. + +- [ ] **Step 5: Add import for `keyword_highlight.dart` at the top of `terminal_view.dart`** + +```dart +import 'package:xterm/src/ui/keyword_highlight.dart'; +``` + +- [ ] **Step 6: Verify the package compiles** + +```bash +cd packages/xterm && flutter analyze +``` + +Expected: no errors. + +- [ ] **Step 7: Commit** + +```bash +git add packages/xterm/lib/src/terminal_view.dart +git commit -m "feat(xterm): thread keywordRules through TerminalView → RenderTerminal" +``` + +--- + +### Task 5: AppKeywordHighlightRule model + tests + +**Files:** +- Create: `app/lib/models/keyword_highlight_rule.dart` +- Create: `app/test/models/keyword_highlight_rule_test.dart` + +- [ ] **Step 1: Write failing tests first** + +Create `app/test/models/keyword_highlight_rule_test.dart`: + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:yourssh/models/keyword_highlight_rule.dart'; + +void main() { + group('AppKeywordHighlightRule', () { + test('toXtermRule compiles literal pattern with escape', () { + final rule = AppKeywordHighlightRule( + id: '1', + label: 'Error', + pattern: 'error[test]', + isRegex: false, + caseSensitive: false, + enabled: true, + foreground: null, + background: Colors.red, + ); + final xterm = rule.toXtermRule(); + expect(xterm, isNotNull); + // Literal match: "error[test]" as a string, not a char class + expect(xterm!.pattern.hasMatch('error[test]'), isTrue); + expect(xterm.pattern.hasMatch('errort'), isFalse); + }); + + test('toXtermRule compiles regex pattern', () { + final rule = AppKeywordHighlightRule( + id: '2', + label: 'OK', + pattern: r'\bok\b', + isRegex: true, + caseSensitive: false, + enabled: true, + foreground: Colors.green, + background: null, + ); + final xterm = rule.toXtermRule(); + expect(xterm, isNotNull); + expect(xterm!.pattern.hasMatch('ok'), isTrue); + expect(xterm.pattern.hasMatch('working'), isFalse); + }); + + test('toXtermRule returns null for invalid regex', () { + final rule = AppKeywordHighlightRule( + id: '3', + label: 'Bad', + pattern: '[unclosed', + isRegex: true, + caseSensitive: false, + enabled: true, + foreground: null, + background: Colors.red, + ); + expect(rule.toXtermRule(), isNull); + }); + + test('caseSensitive: false makes pattern case-insensitive', () { + final rule = AppKeywordHighlightRule( + id: '4', + label: 'Error', + pattern: 'error', + isRegex: false, + caseSensitive: false, + enabled: true, + foreground: null, + background: Colors.red, + ); + final xterm = rule.toXtermRule(); + expect(xterm!.pattern.hasMatch('ERROR'), isTrue); + expect(xterm.pattern.hasMatch('Error'), isTrue); + }); + + test('toJson / fromJson roundtrip', () { + final rule = AppKeywordHighlightRule( + id: 'abc', + label: 'Warning', + pattern: 'warn', + isRegex: false, + caseSensitive: false, + enabled: true, + foreground: const Color(0xFF00FF00), + background: const Color(0xFFFF0000), + ); + final json = rule.toJson(); + final restored = AppKeywordHighlightRule.fromJson(json); + expect(restored.id, rule.id); + expect(restored.label, rule.label); + expect(restored.pattern, rule.pattern); + expect(restored.isRegex, rule.isRegex); + expect(restored.caseSensitive, rule.caseSensitive); + expect(restored.enabled, rule.enabled); + expect(restored.foreground?.value, rule.foreground?.value); + expect(restored.background?.value, rule.background?.value); + }); + + test('kDefaultKeywordHighlightRules all compile without error', () { + for (final rule in kDefaultKeywordHighlightRules) { + expect(rule.toXtermRule(), isNotNull, + reason: '${rule.label} pattern failed to compile'); + } + }); + + test('kDefaultKeywordHighlightRules contains expected labels', () { + final labels = kDefaultKeywordHighlightRules.map((r) => r.label).toSet(); + expect(labels, containsAll(['Error', 'Warning', 'Success', 'Done', 'OK', 'Debug', 'Info'])); + }); + }); +} +``` + +- [ ] **Step 2: Run failing tests** + +```bash +cd app && flutter test test/models/keyword_highlight_rule_test.dart +``` + +Expected: FAIL — `keyword_highlight_rule.dart` doesn't exist yet. + +- [ ] **Step 3: Create the model** + +Create `app/lib/models/keyword_highlight_rule.dart`: + +```dart +import 'package:flutter/material.dart'; +import 'package:uuid/uuid.dart'; +import 'package:xterm/xterm.dart' as xterm; + +class AppKeywordHighlightRule { + final String id; + final String label; + final String pattern; + final bool isRegex; + final bool caseSensitive; + final bool enabled; + final Color? foreground; + final Color? background; + + AppKeywordHighlightRule({ + String? id, + required this.label, + required this.pattern, + required this.isRegex, + required this.caseSensitive, + required this.enabled, + required this.foreground, + required this.background, + }) : id = id ?? const Uuid().v4(); + + /// Compiles this rule into an xterm render-layer rule. + /// Returns null if the regex pattern is invalid — callers should skip null results. + xterm.KeywordHighlightRule? toXtermRule() { + try { + final rawPattern = isRegex ? pattern : RegExp.escape(pattern); + final compiled = RegExp(rawPattern, caseSensitive: caseSensitive); + return xterm.KeywordHighlightRule( + pattern: compiled, + foreground: foreground, + background: background, + ); + } catch (_) { + return null; + } + } + + Map toJson() => { + 'id': id, + 'label': label, + 'pattern': pattern, + 'isRegex': isRegex, + 'caseSensitive': caseSensitive, + 'enabled': enabled, + 'foreground': foreground?.value, + 'background': background?.value, + }; + + factory AppKeywordHighlightRule.fromJson(Map json) { + return AppKeywordHighlightRule( + id: json['id'] as String, + label: json['label'] as String, + pattern: json['pattern'] as String, + isRegex: json['isRegex'] as bool? ?? false, + caseSensitive: json['caseSensitive'] as bool? ?? false, + enabled: json['enabled'] as bool? ?? true, + foreground: json['foreground'] != null + ? Color(json['foreground'] as int) + : null, + background: json['background'] != null + ? Color(json['background'] as int) + : null, + ); + } + + AppKeywordHighlightRule copyWith({ + String? label, + String? pattern, + bool? isRegex, + bool? caseSensitive, + bool? enabled, + Object? foreground = _unset, + Object? background = _unset, + }) { + return AppKeywordHighlightRule( + id: id, + label: label ?? this.label, + pattern: pattern ?? this.pattern, + isRegex: isRegex ?? this.isRegex, + caseSensitive: caseSensitive ?? this.caseSensitive, + enabled: enabled ?? this.enabled, + foreground: foreground is _Unset ? this.foreground : foreground as Color?, + background: background is _Unset ? this.background : background as Color?, + ); + } +} + +class _Unset { + const _Unset(); +} + +const _unset = _Unset(); + +const kMaxKeywordHighlightRules = 20; + +final kDefaultKeywordHighlightRules = [ + AppKeywordHighlightRule( + id: 'default_error', + label: 'Error', + pattern: 'error', + isRegex: false, + caseSensitive: false, + enabled: true, + foreground: null, + background: Color(0xFFB71C1C), // Colors.red.shade900 + ), + AppKeywordHighlightRule( + id: 'default_fail', + label: 'Fail', + pattern: 'fail', + isRegex: false, + caseSensitive: false, + enabled: true, + foreground: null, + background: Color(0xFFB71C1C), + ), + AppKeywordHighlightRule( + id: 'default_warning', + label: 'Warning', + pattern: 'warning', + isRegex: false, + caseSensitive: false, + enabled: true, + foreground: null, + background: Color(0xFFE65100), // Colors.deepOrange.shade900 + ), + AppKeywordHighlightRule( + id: 'default_warn', + label: 'Warn', + pattern: 'warn', + isRegex: false, + caseSensitive: false, + enabled: true, + foreground: null, + background: Color(0xFFE65100), + ), + AppKeywordHighlightRule( + id: 'default_success', + label: 'Success', + pattern: 'success', + isRegex: false, + caseSensitive: false, + enabled: true, + foreground: Color(0xFFA5D6A7), // Colors.green.shade200 + background: null, + ), + AppKeywordHighlightRule( + id: 'default_done', + label: 'Done', + pattern: 'done', + isRegex: false, + caseSensitive: false, + enabled: true, + foreground: Color(0xFFA5D6A7), + background: null, + ), + AppKeywordHighlightRule( + id: 'default_ok', + label: 'OK', + pattern: r'\bok\b', + isRegex: true, + caseSensitive: false, + enabled: true, + foreground: Color(0xFFA5D6A7), + background: null, + ), + AppKeywordHighlightRule( + id: 'default_debug', + label: 'Debug', + pattern: 'debug', + isRegex: false, + caseSensitive: false, + enabled: true, + foreground: Color(0xFF9E9E9E), // Colors.grey.shade500 + background: null, + ), + AppKeywordHighlightRule( + id: 'default_info', + label: 'Info', + pattern: 'info', + isRegex: false, + caseSensitive: false, + enabled: true, + foreground: Color(0xFF80DEEA), // Colors.cyan.shade200 + background: null, + ), +]; +``` + +- [ ] **Step 4: Run tests and verify they pass** + +```bash +cd app && flutter test test/models/keyword_highlight_rule_test.dart +``` + +Expected: all tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add app/lib/models/keyword_highlight_rule.dart app/test/models/keyword_highlight_rule_test.dart +git commit -m "feat: AppKeywordHighlightRule model with toXtermRule, JSON roundtrip, and verbose defaults" +``` + +--- + +### Task 6: SettingsProvider + tests + +**Files:** +- Modify: `app/lib/providers/settings_provider.dart` +- Modify: `app/test/settings_provider_test.dart` + +- [ ] **Step 1: Write failing tests** + +Add to `app/test/settings_provider_test.dart`: + +```dart +import 'dart:convert'; +// (existing imports remain) +import 'package:yourssh/models/keyword_highlight_rule.dart'; + +// inside main(): + + group('keyword highlighting', () { + test('keywordHighlightingEnabled defaults to true', () async { + final provider = SettingsProvider(); + await Future.delayed(Duration.zero); + expect(provider.keywordHighlightingEnabled, isTrue); + }); + + test('keywordHighlightRules defaults to kDefaultKeywordHighlightRules', () async { + final provider = SettingsProvider(); + await Future.delayed(Duration.zero); + expect(provider.keywordHighlightRules.length, + kDefaultKeywordHighlightRules.length); + }); + + test('save persists keywordHighlightingEnabled', () async { + final provider = SettingsProvider(); + await Future.delayed(Duration.zero); + await provider.save(keywordHighlightingEnabled: false); + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getBool('keywordHighlightingEnabled'), isFalse); + }); + + test('save persists keywordHighlightRules as JSON', () async { + final provider = SettingsProvider(); + await Future.delayed(Duration.zero); + final rule = AppKeywordHighlightRule( + id: 'x', + label: 'Test', + pattern: 'test', + isRegex: false, + caseSensitive: false, + enabled: true, + foreground: null, + background: const Color(0xFFFF0000), + ); + await provider.save(keywordHighlightRules: [rule]); + final prefs = await SharedPreferences.getInstance(); + final json = prefs.getString('keywordHighlightRules'); + expect(json, isNotNull); + final decoded = jsonDecode(json!) as List; + expect(decoded.length, 1); + expect(decoded[0]['id'], 'x'); + }); + + test('loads persisted keywordHighlightRules on init', () async { + final rule = AppKeywordHighlightRule( + id: 'y', + label: 'Loaded', + pattern: 'loaded', + isRegex: false, + caseSensitive: false, + enabled: true, + foreground: null, + background: null, + ); + SharedPreferences.setMockInitialValues({ + 'keywordHighlightRules': jsonEncode([rule.toJson()]), + 'keywordHighlightingEnabled': false, + }); + final provider = SettingsProvider(); + await Future.delayed(Duration.zero); + expect(provider.keywordHighlightRules.length, 1); + expect(provider.keywordHighlightRules[0].id, 'y'); + expect(provider.keywordHighlightingEnabled, isFalse); + }); + }); +``` + +- [ ] **Step 2: Run failing tests** + +```bash +cd app && flutter test test/settings_provider_test.dart +``` + +Expected: FAIL — fields don't exist yet. + +- [ ] **Step 3: Add fields to `SettingsProvider`** + +At the top of the fields section in `settings_provider.dart`, add: + +```dart +bool keywordHighlightingEnabled = true; +List keywordHighlightRules = kDefaultKeywordHighlightRules; +``` + +Add import at top of file: +```dart +import 'package:yourssh/models/keyword_highlight_rule.dart'; +``` + +- [ ] **Step 4: Load in `_load()`** + +Inside `_load()`, after the other `prefs.get*` calls: + +```dart +keywordHighlightingEnabled = + prefs.getBool('keywordHighlightingEnabled') ?? true; +final rulesJson = prefs.getString('keywordHighlightRules'); +if (rulesJson != null) { + try { + keywordHighlightRules = (jsonDecode(rulesJson) as List) + .map((j) => AppKeywordHighlightRule.fromJson(j as Map)) + .toList(); + } catch (_) { + keywordHighlightRules = kDefaultKeywordHighlightRules; + } +} +``` + +- [ ] **Step 5: Persist in `save()`** + +Add to the `save()` method signature: +```dart +bool? keywordHighlightingEnabled, +List? keywordHighlightRules, +``` + +Add to the assignment block: +```dart +if (keywordHighlightingEnabled != null) this.keywordHighlightingEnabled = keywordHighlightingEnabled; +if (keywordHighlightRules != null) this.keywordHighlightRules = keywordHighlightRules; +``` + +Add to the `prefs.set*` block: +```dart +await prefs.setBool('keywordHighlightingEnabled', this.keywordHighlightingEnabled); +await prefs.setString('keywordHighlightRules', jsonEncode(this.keywordHighlightRules.map((r) => r.toJson()).toList())); +``` + +- [ ] **Step 6: Run tests and verify they pass** + +```bash +cd app && flutter test test/settings_provider_test.dart +``` + +Expected: all tests pass. + +- [ ] **Step 7: Commit** + +```bash +git add app/lib/providers/settings_provider.dart app/test/settings_provider_test.dart +git commit -m "feat: SettingsProvider — keywordHighlightingEnabled + keywordHighlightRules persistence" +``` + +--- + +### Task 7: Wire compiled rules into app terminal_view.dart + +**Files:** +- Modify: `app/lib/widgets/terminal_view.dart` + +- [ ] **Step 1: Add `context.select` for keyword rules in `_TerminalWidgetState.build()`** + +In `_TerminalWidgetState.build()`, before the `return Stack(...)`, add: + +```dart +final keywordRules = context.select>( + (s) => s.keywordHighlightingEnabled + ? s.keywordHighlightRules + .where((r) => r.enabled) + .map((r) => r.toXtermRule()) + .whereType() + .toList() + : const [], +); +``` + +(`KeywordHighlightRule` here is `xterm.KeywordHighlightRule` — already imported via `package:xterm/xterm.dart`.) + +- [ ] **Step 2: Pass `keywordRules` to `TerminalView`** + +In the `TerminalView(...)` constructor call (around line 440), add: + +```dart +keywordRules: keywordRules, +``` + +- [ ] **Step 3: Verify the app compiles** + +```bash +cd app && flutter analyze +``` + +Expected: no errors. + +- [ ] **Step 4: Smoke test — run the app and verify highlighting appears** + +```bash +cd app && flutter run -d macos +``` + +Open a terminal session. Type `echo error` — the word "error" should appear with a dark red background. Type `echo success` — "success" should appear in green text. If both work, the render layer is wired correctly. + +- [ ] **Step 5: Commit** + +```bash +git add app/lib/widgets/terminal_view.dart +git commit -m "feat: wire keyword highlight rules from SettingsProvider into TerminalView" +``` + +--- + +### Task 8: Settings screen UI — keyword highlighting section + +**Files:** +- Create: `app/lib/widgets/keyword_highlight_settings.dart` +- Modify: `app/lib/widgets/settings_screen.dart` + +- [ ] **Step 1: Create `app/lib/widgets/keyword_highlight_settings.dart`** + +This file contains: +- `KeywordHighlightSection` — the full settings section (rule list + add button + master toggle) +- `_RuleRow` — individual rule row widget +- `_KeywordRuleDialog` — add/edit dialog +- `_ColorPickerButton` — nullable color selector +- `_kPresetColors` — list of preset colors + +```dart +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../models/keyword_highlight_rule.dart'; +import '../providers/settings_provider.dart'; +import '../theme/app_theme.dart'; + +// Preset colors suitable for terminal highlighting +const _kForegroundPresets = [ + Color(0xFFEF9A9A), // red.shade200 + Color(0xFFFFCC80), // orange.shade200 + Color(0xFFFFF176), // yellow.shade300 + Color(0xFFA5D6A7), // green.shade200 + Color(0xFF80DEEA), // cyan.shade200 + Color(0xFF90CAF9), // blue.shade200 + Color(0xFFCE93D8), // purple.shade200 + Color(0xFFFFFFFF), // white + Color(0xFF9E9E9E), // grey.shade500 + Color(0xFFBDBDBD), // grey.shade400 +]; + +const _kBackgroundPresets = [ + Color(0xFFB71C1C), // red.shade900 + Color(0xFFE65100), // deepOrange.shade900 + Color(0xFFF57F17), // amber.shade900 + Color(0xFF1B5E20), // green.shade900 + Color(0xFF006064), // cyan.shade900 + Color(0xFF0D47A1), // blue.shade900 + Color(0xFF4A148C), // purple.shade900 + Color(0xFF37474F), // blueGrey.shade800 +]; + +class KeywordHighlightSection extends StatelessWidget { + const KeywordHighlightSection({super.key}); + + @override + Widget build(BuildContext context) { + final settings = context.watch(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Master toggle + SwitchListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 16), + title: const Text('Enable keyword highlighting', + style: TextStyle(color: AppColors.textPrimary, fontSize: 13)), + subtitle: const Text( + 'Tint matching text in all terminal sessions', + style: TextStyle(color: AppColors.textSecondary, fontSize: 11)), + value: settings.keywordHighlightingEnabled, + onChanged: (v) => context + .read() + .save(keywordHighlightingEnabled: v), + ), + const Divider(height: 1, color: AppColors.border, indent: 16), + // Rule list + ...settings.keywordHighlightRules.asMap().entries.map((entry) { + final i = entry.key; + final rule = entry.value; + return Column( + children: [ + _RuleRow( + rule: rule, + onToggle: (enabled) { + final updated = List.from( + settings.keywordHighlightRules); + updated[i] = rule.copyWith(enabled: enabled); + context + .read() + .save(keywordHighlightRules: updated); + }, + onEdit: () => _showRuleDialog(context, settings, rule: rule, index: i), + onDelete: () { + final updated = List.from( + settings.keywordHighlightRules) + ..removeAt(i); + context + .read() + .save(keywordHighlightRules: updated); + }, + ), + if (i < settings.keywordHighlightRules.length - 1) + const Divider(height: 1, color: AppColors.border, indent: 16), + ], + ); + }), + // Add rule button + if (settings.keywordHighlightRules.length < kMaxKeywordHighlightRules) + Column( + children: [ + const Divider(height: 1, color: AppColors.border, indent: 16), + ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 16), + leading: const Icon(Icons.add, color: AppColors.accent, size: 18), + title: const Text('Add rule', + style: TextStyle(color: AppColors.accent, fontSize: 13)), + onTap: () => _showRuleDialog(context, settings), + ), + ], + ), + if (settings.keywordHighlightRules.length >= kMaxKeywordHighlightRules) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Text( + 'Maximum $kMaxKeywordHighlightRules rules reached.', + style: const TextStyle( + color: AppColors.textSecondary, fontSize: 11), + ), + ), + ], + ); + } + + Future _showRuleDialog( + BuildContext context, + SettingsProvider settings, { + AppKeywordHighlightRule? rule, + int? index, + }) async { + final result = await showDialog( + context: context, + builder: (_) => _KeywordRuleDialog(initial: rule), + ); + if (result == null || !context.mounted) return; + final updated = List.from(settings.keywordHighlightRules); + if (index != null) { + updated[index] = result; + } else { + updated.add(result); + } + context.read().save(keywordHighlightRules: updated); + } +} + +class _RuleRow extends StatelessWidget { + final AppKeywordHighlightRule rule; + final ValueChanged onToggle; + final VoidCallback onEdit; + final VoidCallback onDelete; + + const _RuleRow({ + required this.rule, + required this.onToggle, + required this.onEdit, + required this.onDelete, + }); + + @override + Widget build(BuildContext context) { + return ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 2), + leading: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (rule.background != null) + _ColorDot(color: rule.background!, label: 'bg'), + if (rule.foreground != null) ...[ + if (rule.background != null) const SizedBox(width: 4), + _ColorDot(color: rule.foreground!, label: 'fg', border: true), + ], + ], + ), + title: Text(rule.label, + style: const TextStyle(color: AppColors.textPrimary, fontSize: 13)), + subtitle: Text( + '${rule.isRegex ? "regex" : "literal"} · ${rule.pattern}', + style: const TextStyle(color: AppColors.textSecondary, fontSize: 11, fontFamily: 'monospace'), + overflow: TextOverflow.ellipsis, + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Switch( + value: rule.enabled, + onChanged: onToggle, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + IconButton( + icon: const Icon(Icons.edit_outlined, size: 16, color: AppColors.textSecondary), + onPressed: onEdit, + tooltip: 'Edit rule', + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 28, minHeight: 28), + ), + IconButton( + icon: const Icon(Icons.delete_outline, size: 16, color: AppColors.textSecondary), + onPressed: onDelete, + tooltip: 'Delete rule', + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 28, minHeight: 28), + ), + ], + ), + ); + } +} + +class _ColorDot extends StatelessWidget { + final Color color; + final String label; + final bool border; + + const _ColorDot({required this.color, required this.label, this.border = false}); + + @override + Widget build(BuildContext context) { + return Tooltip( + message: label, + child: Container( + width: 14, + height: 14, + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + border: border + ? Border.all(color: AppColors.textSecondary, width: 1.5) + : null, + ), + ), + ); + } +} + +class _KeywordRuleDialog extends StatefulWidget { + final AppKeywordHighlightRule? initial; + const _KeywordRuleDialog({this.initial}); + + @override + State<_KeywordRuleDialog> createState() => _KeywordRuleDialogState(); +} + +class _KeywordRuleDialogState extends State<_KeywordRuleDialog> { + late final TextEditingController _labelCtrl; + late final TextEditingController _patternCtrl; + late bool _isRegex; + late bool _caseSensitive; + Color? _foreground; + Color? _background; + String? _regexError; + + @override + void initState() { + super.initState(); + final r = widget.initial; + _labelCtrl = TextEditingController(text: r?.label ?? ''); + _patternCtrl = TextEditingController(text: r?.pattern ?? ''); + _isRegex = r?.isRegex ?? false; + _caseSensitive = r?.caseSensitive ?? false; + _foreground = r?.foreground; + _background = r?.background; + } + + @override + void dispose() { + _labelCtrl.dispose(); + _patternCtrl.dispose(); + super.dispose(); + } + + void _validatePattern() { + if (!_isRegex) { + setState(() => _regexError = null); + return; + } + try { + RegExp(_patternCtrl.text); + setState(() => _regexError = null); + } catch (e) { + setState(() => _regexError = e.toString()); + } + } + + bool get _isValid => + _labelCtrl.text.trim().isNotEmpty && + _patternCtrl.text.isNotEmpty && + _regexError == null && + (_foreground != null || _background != null); + + @override + Widget build(BuildContext context) { + return AlertDialog( + backgroundColor: AppColors.surface, + title: Text( + widget.initial == null ? 'Add rule' : 'Edit rule', + style: const TextStyle(color: AppColors.textPrimary, fontSize: 15), + ), + content: SizedBox( + width: 380, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _field('Label', _labelCtrl, hint: 'e.g. Error'), + const SizedBox(height: 12), + _field( + 'Pattern', + _patternCtrl, + hint: _isRegex ? r'e.g. \berror\b' : 'e.g. error', + monospace: true, + onChanged: (_) => _validatePattern(), + errorText: _regexError, + ), + const SizedBox(height: 8), + Row( + children: [ + Checkbox( + value: _isRegex, + onChanged: (v) => + setState(() => _isRegex = v ?? false), + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + const Text('Regex', + style: TextStyle(color: AppColors.textPrimary, fontSize: 13)), + const SizedBox(width: 16), + Checkbox( + value: _caseSensitive, + onChanged: (v) => + setState(() => _caseSensitive = v ?? false), + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + const Text('Case-sensitive', + style: TextStyle(color: AppColors.textPrimary, fontSize: 13)), + ], + ), + const SizedBox(height: 12), + _ColorPickerButton( + label: 'Foreground (text color)', + current: _foreground, + presets: _kForegroundPresets, + onChanged: (c) => setState(() => _foreground = c), + ), + const SizedBox(height: 8), + _ColorPickerButton( + label: 'Background', + current: _background, + presets: _kBackgroundPresets, + onChanged: (c) => setState(() => _background = c), + ), + if (!_isValid && _labelCtrl.text.isNotEmpty && _patternCtrl.text.isNotEmpty) + const Padding( + padding: EdgeInsets.only(top: 8), + child: Text( + 'Choose at least one color.', + style: TextStyle(color: Colors.red, fontSize: 11), + ), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: _isValid + ? () { + Navigator.pop( + context, + AppKeywordHighlightRule( + id: widget.initial?.id, + label: _labelCtrl.text.trim(), + pattern: _patternCtrl.text, + isRegex: _isRegex, + caseSensitive: _caseSensitive, + enabled: widget.initial?.enabled ?? true, + foreground: _foreground, + background: _background, + ), + ); + } + : null, + child: const Text('Save'), + ), + ], + ); + } + + Widget _field( + String label, + TextEditingController ctrl, { + String? hint, + bool monospace = false, + ValueChanged? onChanged, + String? errorText, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, + style: const TextStyle( + color: AppColors.textSecondary, fontSize: 11, fontWeight: FontWeight.w600)), + const SizedBox(height: 4), + TextField( + controller: ctrl, + style: TextStyle( + color: AppColors.textPrimary, + fontSize: 13, + fontFamily: monospace ? 'monospace' : null, + ), + decoration: InputDecoration( + hintText: hint, + hintStyle: const TextStyle(color: AppColors.textTertiary, fontSize: 12), + errorText: errorText, + isDense: true, + contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + border: const OutlineInputBorder(), + ), + onChanged: onChanged, + ), + ], + ); + } +} + +class _ColorPickerButton extends StatelessWidget { + final String label; + final Color? current; + final List presets; + final ValueChanged onChanged; + + const _ColorPickerButton({ + required this.label, + required this.current, + required this.presets, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded( + child: Text(label, + style: const TextStyle(color: AppColors.textSecondary, fontSize: 12)), + ), + GestureDetector( + onTap: () => _pick(context), + child: Container( + width: 80, + height: 28, + decoration: BoxDecoration( + color: current ?? Colors.transparent, + border: Border.all(color: AppColors.border), + borderRadius: BorderRadius.circular(4), + ), + alignment: Alignment.center, + child: current == null + ? const Text('None', + style: TextStyle(color: AppColors.textSecondary, fontSize: 11)) + : null, + ), + ), + ], + ); + } + + Future _pick(BuildContext context) async { + final result = await showDialog( + context: context, + builder: (_) => _ColorGridDialog(presets: presets, current: current), + ); + // result == null means dialog dismissed; result == _clearSentinel means "None" + if (result == _clearSentinel) { + onChanged(null); + } else if (result != null) { + onChanged(result); + } + } +} + +// Sentinel value returned by the dialog when the user picks "None" +final _clearSentinel = const Color(0x00000000); + +class _ColorGridDialog extends StatelessWidget { + final List presets; + final Color? current; + const _ColorGridDialog({required this.presets, required this.current}); + + @override + Widget build(BuildContext context) { + return AlertDialog( + backgroundColor: AppColors.surface, + title: const Text('Pick color', + style: TextStyle(color: AppColors.textPrimary, fontSize: 14)), + content: Wrap( + spacing: 8, + runSpacing: 8, + children: [ + // "None" option + GestureDetector( + onTap: () => Navigator.pop(context, _clearSentinel), + child: Container( + width: 32, + height: 32, + decoration: BoxDecoration( + border: Border.all(color: AppColors.border), + borderRadius: BorderRadius.circular(4), + ), + alignment: Alignment.center, + child: const Text('✕', + style: TextStyle(color: AppColors.textSecondary, fontSize: 12)), + ), + ), + ...presets.map((c) => GestureDetector( + onTap: () => Navigator.pop(context, c), + child: Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: c, + borderRadius: BorderRadius.circular(4), + border: current == c + ? Border.all(color: Colors.white, width: 2) + : null, + ), + ), + )), + ], + ), + ); + } +} +``` + +- [ ] **Step 2: Add the keyword highlighting section to `settings_screen.dart`** + +Add the import at the top of `settings_screen.dart`: +```dart +import 'keyword_highlight_settings.dart'; +``` + +After the Terminal `_Section` (after line 148, around the `const SizedBox(height: 24)` before Recording), insert: + +```dart +const SizedBox(height: 24), +_Section(title: 'Keyword Highlighting', children: [ + const KeywordHighlightSection(), +]), +``` + +- [ ] **Step 3: Verify the app compiles** + +```bash +cd app && flutter analyze +``` + +Expected: no errors. + +- [ ] **Step 4: Run the app and test the settings UI manually** + +```bash +cd app && flutter run -d macos +``` + +- Navigate to Settings → Terminal → scroll to "Keyword Highlighting" +- Toggle master enable off/on — verify terminal highlighting updates immediately +- Edit the "Error" rule — change background color — verify terminal updates +- Add a new rule with a literal pattern — type that word in a terminal session — verify highlight appears +- Add a rule with an invalid regex `[bad` — verify "Invalid regex" error appears inline +- Delete a rule — verify it disappears from terminal output + +- [ ] **Step 5: Commit** + +```bash +git add app/lib/widgets/keyword_highlight_settings.dart app/lib/widgets/settings_screen.dart +git commit -m "feat: keyword highlighting settings UI — rule list, add/edit dialog, color picker" +``` + +--- + +### Task 9: Terminal config panel compact section + +**Files:** +- Modify: `app/lib/widgets/terminal_config_panel.dart` + +- [ ] **Step 1: Update `terminal_config_panel.dart`** + +Replace the entire file: + +```dart +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../models/keyword_highlight_rule.dart'; +import '../providers/settings_provider.dart'; +import '../theme/app_theme.dart'; +import 'terminal_appearance_controls.dart'; +import 'workspace_side_panel.dart'; + +class TerminalConfigPanel extends StatelessWidget { + final VoidCallback? onClose; + final VoidCallback? onOpenSettings; + + const TerminalConfigPanel({super.key, this.onClose, this.onOpenSettings}); + + @override + Widget build(BuildContext context) { + return WorkspaceSidePanel( + title: 'Terminal', + closeTooltip: 'Close terminal settings', + onClose: onClose, + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + const TerminalAppearanceControls( + layout: AppearanceControlsLayout.vertical, + ), + const SizedBox(height: 20), + _KeywordHighlightCompact(onOpenSettings: onOpenSettings), + ], + ), + ); + } +} + +class _KeywordHighlightCompact extends StatelessWidget { + final VoidCallback? onOpenSettings; + const _KeywordHighlightCompact({this.onOpenSettings}); + + @override + Widget build(BuildContext context) { + final settings = context.watch(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'KEYWORD HIGHLIGHTING', + style: TextStyle( + color: AppColors.textSecondary, + fontSize: 11, + fontWeight: FontWeight.w600, + letterSpacing: 0.5, + ), + ), + const SizedBox(height: 8), + // Master toggle + Row( + children: [ + const Expanded( + child: Text('Enable', + style: TextStyle( + color: AppColors.textPrimary, fontSize: 13)), + ), + Switch( + value: settings.keywordHighlightingEnabled, + onChanged: (v) => context + .read() + .save(keywordHighlightingEnabled: v), + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + ], + ), + const SizedBox(height: 4), + // Per-rule compact rows + ...settings.keywordHighlightRules.asMap().entries.map((entry) { + final i = entry.key; + final rule = entry.value; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + children: [ + if (rule.background != null) + _Dot(color: rule.background!), + if (rule.foreground != null) ...[ + if (rule.background != null) const SizedBox(width: 4), + _Dot(color: rule.foreground!, border: true), + ], + const SizedBox(width: 8), + Expanded( + child: Text( + rule.label, + style: const TextStyle( + color: AppColors.textPrimary, fontSize: 12), + ), + ), + Switch( + value: rule.enabled, + onChanged: (v) { + final updated = List.from( + settings.keywordHighlightRules); + updated[i] = rule.copyWith(enabled: v); + context + .read() + .save(keywordHighlightRules: updated); + }, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + ], + ), + ); + }), + const SizedBox(height: 8), + // Link to full settings + if (onOpenSettings != null) + GestureDetector( + onTap: onOpenSettings, + child: const Text( + 'Manage rules in Settings →', + style: TextStyle( + color: AppColors.accent, + fontSize: 12, + decoration: TextDecoration.underline), + ), + ), + ], + ); + } +} + +class _Dot extends StatelessWidget { + final Color color; + final bool border; + const _Dot({required this.color, this.border = false}); + + @override + Widget build(BuildContext context) { + return Container( + width: 12, + height: 12, + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + border: border + ? Border.all(color: AppColors.textSecondary, width: 1) + : null, + ), + ); + } +} +``` + +- [ ] **Step 2: Wire `onOpenSettings` where `TerminalConfigPanel` is instantiated** + +Find where `TerminalConfigPanel` is created (search for `TerminalConfigPanel(` in the codebase) and pass `onOpenSettings`: + +```bash +grep -rn "TerminalConfigPanel(" app/lib/ --include="*.dart" +``` + +In the calling widget, add `onOpenSettings: () { /* navigate to Settings → Terminal */ }`. The exact navigation depends on the calling context — typically something like `context.read().navigateTo(NavSection.settings)` or similar. Check the calling file and use the existing navigation pattern. + +- [ ] **Step 3: Verify the app compiles** + +```bash +cd app && flutter analyze +``` + +Expected: no errors. + +- [ ] **Step 4: Run the app and test manually** + +```bash +cd app && flutter run -d macos +``` + +- Open a terminal session +- Click the tune icon to open the Terminal config panel +- Verify "KEYWORD HIGHLIGHTING" section appears with master toggle and per-rule toggles +- Toggle a rule off — verify the highlight disappears in the terminal immediately +- Toggle it back on — verify the highlight reappears + +- [ ] **Step 5: Commit** + +```bash +git add app/lib/widgets/terminal_config_panel.dart +git commit -m "feat: terminal config panel — compact keyword highlight toggle section" +``` + +--- + +## Self-Review Checklist + +- **Spec coverage:** + - ✅ Global rules only — `SettingsProvider` holds one list, no per-host override + - ✅ Both foreground + background color — `KeywordHighlightRule` has both nullable fields + - ✅ Verbose defaults — 9 rules in `kDefaultKeywordHighlightRules` + - ✅ Settings → Terminal section — `KeywordHighlightSection` in `settings_screen.dart` + - ✅ Workspace side panel — `_KeywordHighlightCompact` in `terminal_config_panel.dart` + - ✅ SSH + local shell — render layer is independent of `SshService` + - ✅ Recordings unaffected — no data mutation + - ✅ Max 20 rules enforced — `kMaxKeywordHighlightRules` in model + UI guard + - ✅ Invalid regex gracefully handled — `toXtermRule()` returns null + dialog validates + - ✅ Wide char handling — `if (charWidth == 2) i++` in `paintKeywordForeground` + +- **Type consistency:** + - `AppKeywordHighlightRule` throughout app layer + - `xterm.KeywordHighlightRule` (from `packages/xterm`) throughout render layer + - `kDefaultKeywordHighlightRules` and `kMaxKeywordHighlightRules` defined in `app/lib/models/keyword_highlight_rule.dart` + - `save(keywordHighlightRules:, keywordHighlightingEnabled:)` in `SettingsProvider` + - `_paintKeywordHighlights(canvas, firstLine, lastLine)` in `RenderTerminal` + - `paintKeywordForeground(canvas, lineOffset, line, startCol, endCol, fgColor)` in `TerminalPainter` diff --git a/packages/xterm/lib/ui.dart b/packages/xterm/lib/ui.dart index db0cddbc..5ac5a7e9 100644 --- a/packages/xterm/lib/ui.dart +++ b/packages/xterm/lib/ui.dart @@ -2,7 +2,7 @@ export 'src/terminal_view.dart'; export 'src/ui/clipboard_ops.dart'; export 'src/ui/controller.dart'; export 'src/ui/cursor_type.dart'; -export 'src/ui/keyboard_highlight.dart'; +export 'src/ui/keyword_highlight.dart'; export 'src/ui/keyboard_visibility.dart'; export 'src/ui/pointer_input.dart'; export 'src/ui/selection_mode.dart'; From 6d0f41ee07363c7cd38c89d1695186568305275f Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Sun, 7 Jun 2026 22:25:53 +0700 Subject: [PATCH 04/59] =?UTF-8?q?feat(xterm):=20paintKeywordForeground=20?= =?UTF-8?q?=E2=80=94=20re-render=20cells=20with=20override=20color?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/xterm/lib/src/ui/painter.dart | 31 ++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/packages/xterm/lib/src/ui/painter.dart b/packages/xterm/lib/src/ui/painter.dart index 64a78e08..bbb87ce4 100644 --- a/packages/xterm/lib/src/ui/painter.dart +++ b/packages/xterm/lib/src/ui/painter.dart @@ -137,6 +137,37 @@ class TerminalPainter { ); } + void paintKeywordForeground( + Canvas canvas, + Offset lineOffset, + BufferLine line, + int startCol, + int endCol, + Color fgColor, + ) { + final cellData = CellData.empty(); + final cellWidth = _cellSize.width; + + for (var i = startCol; i < endCol && i < line.length; i++) { + line.getCellData(i, cellData); + final charCode = cellData.content & CellContent.codepointMask; + final charWidth = cellData.content >> CellContent.widthShift; + + if (charCode != 0) { + final style = _textStyle.toTextStyle(color: fgColor); + final builder = ParagraphBuilder(style.getParagraphStyle()) + ..pushStyle(style.getTextStyle(textScaler: _textScaler)) + ..addText(String.fromCharCode(charCode)); + final para = builder.build() + ..layout(ParagraphConstraints(width: cellWidth * 2)); + canvas.drawParagraph(para, lineOffset.translate(i * cellWidth, 0)); + para.dispose(); + } + + if (charWidth == 2) i++; + } + } + /// Paints [line] to [canvas] at [offset]. The x offset of [offset] is usually /// 0, and the y offset is the top of the line. void paintLine( From c2e151a9a5e60fd9850c160a451836cbf7036e8a Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Sun, 7 Jun 2026 22:28:32 +0700 Subject: [PATCH 05/59] feat(xterm): render keyword highlights on visible lines at paint time --- packages/xterm/lib/src/ui/render.dart | 48 +++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/packages/xterm/lib/src/ui/render.dart b/packages/xterm/lib/src/ui/render.dart index de2a2f7f..708f23e1 100644 --- a/packages/xterm/lib/src/ui/render.dart +++ b/packages/xterm/lib/src/ui/render.dart @@ -16,6 +16,7 @@ import 'package:xterm/src/ui/selection_mode.dart'; import 'package:xterm/src/ui/terminal_size.dart'; import 'package:xterm/src/ui/terminal_text_style.dart'; import 'package:xterm/src/ui/terminal_theme.dart'; +import 'package:xterm/src/ui/keyword_highlight.dart'; typedef EditableRectCallback = void Function(Rect rect, Rect caretRect); @@ -151,6 +152,13 @@ class RenderTerminal extends RenderBox with RelayoutWhenSystemFontsChangeMixin { final TerminalPainter _painter; + List _keywordRules = const []; + set keywordRules(List value) { + if (_keywordRules == value) return; + _keywordRules = value; + markNeedsPaint(); + } + var _stickToBottom = true; void _onScroll() { @@ -421,6 +429,8 @@ class RenderTerminal extends RenderBox with RelayoutWhenSystemFontsChangeMixin { ); } + _paintKeywordHighlights(canvas, effectFirstLine, effectLastLine); + if (_terminal.buffer.absoluteCursorY >= effectFirstLine && _terminal.buffer.absoluteCursorY <= effectLastLine) { if (_isComposingText) { @@ -549,4 +559,42 @@ class RenderTerminal extends RenderBox with RelayoutWhenSystemFontsChangeMixin { _painter.paintHighlight(canvas, startOffset, end - start, color); } + + void _paintKeywordHighlights(Canvas canvas, int firstLine, int lastLine) { + if (_keywordRules.isEmpty) return; + final lines = _terminal.buffer.lines; + final charHeight = _painter.cellSize.height; + + for (var i = firstLine; i <= lastLine; i++) { + if (i >= lines.length) break; + final lineText = lines[i].getText(); + final lineY = i * charHeight + _lineOffset; + + for (final rule in _keywordRules) { + for (final m in rule.pattern.allMatches(lineText)) { + if (m.start == m.end) continue; + + if (rule.background != null) { + _painter.paintHighlight( + canvas, + Offset(m.start * _painter.cellSize.width, lineY), + m.end - m.start, + rule.background!, + ); + } + + if (rule.foreground != null) { + _painter.paintKeywordForeground( + canvas, + Offset(0, lineY), + lines[i], + m.start, + m.end, + rule.foreground!, + ); + } + } + } + } + } } From b8173638a367003ab3347165fafcdd82b8e3f23e Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Sun, 7 Jun 2026 22:30:14 +0700 Subject: [PATCH 06/59] =?UTF-8?q?feat(xterm):=20thread=20keywordRules=20th?= =?UTF-8?q?rough=20TerminalView=20=E2=86=92=20RenderTerminal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/xterm/lib/src/terminal_view.dart | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/packages/xterm/lib/src/terminal_view.dart b/packages/xterm/lib/src/terminal_view.dart index 6f85cb14..75673550 100644 --- a/packages/xterm/lib/src/terminal_view.dart +++ b/packages/xterm/lib/src/terminal_view.dart @@ -7,6 +7,7 @@ import 'package:xterm/src/core/buffer/cell_offset.dart'; import 'package:xterm/src/core/input/keys.dart'; import 'package:xterm/src/terminal.dart'; import 'package:xterm/src/ui/clipboard_ops.dart'; +import 'package:xterm/src/ui/keyword_highlight.dart'; import 'package:xterm/src/ui/controller.dart'; import 'package:xterm/src/ui/cursor_type.dart'; import 'package:xterm/src/ui/custom_text_edit.dart'; @@ -50,6 +51,7 @@ class TerminalView extends StatefulWidget { this.readOnly = false, this.hardwareKeyboardOnly = false, this.simulateScroll = true, + this.keywordRules = const [], }); /// The underlying terminal that this widget renders. @@ -143,6 +145,9 @@ class TerminalView extends StatefulWidget { /// emulators. True by default. final bool simulateScroll; + /// Keyword highlighting rules to apply to terminal output. + final List keywordRules; + @override State createState() => TerminalViewState(); } @@ -238,6 +243,7 @@ class TerminalViewState extends State { alwaysShowCursor: widget.alwaysShowCursor, onEditableRect: _onEditableRect, composingText: _composingText, + keywordRules: widget.keywordRules, ); }, ); @@ -481,6 +487,7 @@ class _TerminalView extends LeafRenderObjectWidget { required this.alwaysShowCursor, this.onEditableRect, this.composingText, + required this.keywordRules, }); final Terminal terminal; @@ -509,9 +516,11 @@ class _TerminalView extends LeafRenderObjectWidget { final String? composingText; + final List keywordRules; + @override RenderTerminal createRenderObject(BuildContext context) { - return RenderTerminal( + final renderObject = RenderTerminal( terminal: terminal, controller: controller, offset: offset, @@ -526,6 +535,8 @@ class _TerminalView extends LeafRenderObjectWidget { onEditableRect: onEditableRect, composingText: composingText, ); + renderObject.keywordRules = keywordRules; + return renderObject; } @override @@ -543,6 +554,7 @@ class _TerminalView extends LeafRenderObjectWidget { ..cursorType = cursorType ..alwaysShowCursor = alwaysShowCursor ..onEditableRect = onEditableRect - ..composingText = composingText; + ..composingText = composingText + ..keywordRules = keywordRules; } } From a4ed67fc3570eb3c122d08ff4a73322b470550d4 Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Sun, 7 Jun 2026 22:32:10 +0700 Subject: [PATCH 07/59] feat: AppKeywordHighlightRule model with toXtermRule, JSON roundtrip, and verbose defaults --- app/lib/models/keyword_highlight_rule.dart | 189 ++++++++++++++++++ .../models/keyword_highlight_rule_test.dart | 107 ++++++++++ 2 files changed, 296 insertions(+) create mode 100644 app/lib/models/keyword_highlight_rule.dart create mode 100644 app/test/models/keyword_highlight_rule_test.dart diff --git a/app/lib/models/keyword_highlight_rule.dart b/app/lib/models/keyword_highlight_rule.dart new file mode 100644 index 00000000..89be9e55 --- /dev/null +++ b/app/lib/models/keyword_highlight_rule.dart @@ -0,0 +1,189 @@ +import 'package:flutter/material.dart'; +import 'package:uuid/uuid.dart'; +import 'package:xterm/xterm.dart' as xterm; + +class AppKeywordHighlightRule { + final String id; + final String label; + final String pattern; + final bool isRegex; + final bool caseSensitive; + final bool enabled; + final Color? foreground; + final Color? background; + + AppKeywordHighlightRule({ + String? id, + required this.label, + required this.pattern, + required this.isRegex, + required this.caseSensitive, + required this.enabled, + required this.foreground, + required this.background, + }) : id = id ?? const Uuid().v4(); + + xterm.KeywordHighlightRule? toXtermRule() { + try { + final rawPattern = isRegex ? pattern : RegExp.escape(pattern); + final compiled = RegExp(rawPattern, caseSensitive: caseSensitive); + return xterm.KeywordHighlightRule( + pattern: compiled, + foreground: foreground, + background: background, + ); + } catch (_) { + return null; + } + } + + Map toJson() => { + 'id': id, + 'label': label, + 'pattern': pattern, + 'isRegex': isRegex, + 'caseSensitive': caseSensitive, + 'enabled': enabled, + 'foreground': foreground?.value, + 'background': background?.value, + }; + + factory AppKeywordHighlightRule.fromJson(Map json) { + return AppKeywordHighlightRule( + id: json['id'] as String, + label: json['label'] as String, + pattern: json['pattern'] as String, + isRegex: json['isRegex'] as bool? ?? false, + caseSensitive: json['caseSensitive'] as bool? ?? false, + enabled: json['enabled'] as bool? ?? true, + foreground: json['foreground'] != null + ? Color(json['foreground'] as int) + : null, + background: json['background'] != null + ? Color(json['background'] as int) + : null, + ); + } + + AppKeywordHighlightRule copyWith({ + String? label, + String? pattern, + bool? isRegex, + bool? caseSensitive, + bool? enabled, + Object? foreground = _unset, + Object? background = _unset, + }) { + return AppKeywordHighlightRule( + id: id, + label: label ?? this.label, + pattern: pattern ?? this.pattern, + isRegex: isRegex ?? this.isRegex, + caseSensitive: caseSensitive ?? this.caseSensitive, + enabled: enabled ?? this.enabled, + foreground: foreground is _Unset ? this.foreground : foreground as Color?, + background: background is _Unset ? this.background : background as Color?, + ); + } +} + +class _Unset { + const _Unset(); +} + +const _unset = _Unset(); + +const kMaxKeywordHighlightRules = 20; + +final kDefaultKeywordHighlightRules = [ + AppKeywordHighlightRule( + id: 'default_error', + label: 'Error', + pattern: 'error', + isRegex: false, + caseSensitive: false, + enabled: true, + foreground: null, + background: const Color(0xFFB71C1C), + ), + AppKeywordHighlightRule( + id: 'default_fail', + label: 'Fail', + pattern: 'fail', + isRegex: false, + caseSensitive: false, + enabled: true, + foreground: null, + background: const Color(0xFFB71C1C), + ), + AppKeywordHighlightRule( + id: 'default_warning', + label: 'Warning', + pattern: 'warning', + isRegex: false, + caseSensitive: false, + enabled: true, + foreground: null, + background: const Color(0xFFE65100), + ), + AppKeywordHighlightRule( + id: 'default_warn', + label: 'Warn', + pattern: 'warn', + isRegex: false, + caseSensitive: false, + enabled: true, + foreground: null, + background: const Color(0xFFE65100), + ), + AppKeywordHighlightRule( + id: 'default_success', + label: 'Success', + pattern: 'success', + isRegex: false, + caseSensitive: false, + enabled: true, + foreground: const Color(0xFFA5D6A7), + background: null, + ), + AppKeywordHighlightRule( + id: 'default_done', + label: 'Done', + pattern: 'done', + isRegex: false, + caseSensitive: false, + enabled: true, + foreground: const Color(0xFFA5D6A7), + background: null, + ), + AppKeywordHighlightRule( + id: 'default_ok', + label: 'OK', + pattern: r'\bok\b', + isRegex: true, + caseSensitive: false, + enabled: true, + foreground: const Color(0xFFA5D6A7), + background: null, + ), + AppKeywordHighlightRule( + id: 'default_debug', + label: 'Debug', + pattern: 'debug', + isRegex: false, + caseSensitive: false, + enabled: true, + foreground: const Color(0xFF9E9E9E), + background: null, + ), + AppKeywordHighlightRule( + id: 'default_info', + label: 'Info', + pattern: 'info', + isRegex: false, + caseSensitive: false, + enabled: true, + foreground: const Color(0xFF80DEEA), + background: null, + ), +]; diff --git a/app/test/models/keyword_highlight_rule_test.dart b/app/test/models/keyword_highlight_rule_test.dart new file mode 100644 index 00000000..c6069c85 --- /dev/null +++ b/app/test/models/keyword_highlight_rule_test.dart @@ -0,0 +1,107 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:yourssh/models/keyword_highlight_rule.dart'; + +void main() { + group('AppKeywordHighlightRule', () { + test('toXtermRule compiles literal pattern with escape', () { + final rule = AppKeywordHighlightRule( + id: '1', + label: 'Error', + pattern: 'error[test]', + isRegex: false, + caseSensitive: false, + enabled: true, + foreground: null, + background: Colors.red, + ); + final xterm = rule.toXtermRule(); + expect(xterm, isNotNull); + // Literal match: "error[test]" as a string, not a char class + expect(xterm!.pattern.hasMatch('error[test]'), isTrue); + expect(xterm.pattern.hasMatch('errort'), isFalse); + }); + + test('toXtermRule compiles regex pattern', () { + final rule = AppKeywordHighlightRule( + id: '2', + label: 'OK', + pattern: r'\bok\b', + isRegex: true, + caseSensitive: false, + enabled: true, + foreground: Colors.green, + background: null, + ); + final xterm = rule.toXtermRule(); + expect(xterm, isNotNull); + expect(xterm!.pattern.hasMatch('ok'), isTrue); + expect(xterm.pattern.hasMatch('working'), isFalse); + }); + + test('toXtermRule returns null for invalid regex', () { + final rule = AppKeywordHighlightRule( + id: '3', + label: 'Bad', + pattern: '[unclosed', + isRegex: true, + caseSensitive: false, + enabled: true, + foreground: null, + background: Colors.red, + ); + expect(rule.toXtermRule(), isNull); + }); + + test('caseSensitive: false makes pattern case-insensitive', () { + final rule = AppKeywordHighlightRule( + id: '4', + label: 'Error', + pattern: 'error', + isRegex: false, + caseSensitive: false, + enabled: true, + foreground: null, + background: Colors.red, + ); + final xterm = rule.toXtermRule(); + expect(xterm!.pattern.hasMatch('ERROR'), isTrue); + expect(xterm.pattern.hasMatch('Error'), isTrue); + }); + + test('toJson / fromJson roundtrip', () { + final rule = AppKeywordHighlightRule( + id: 'abc', + label: 'Warning', + pattern: 'warn', + isRegex: false, + caseSensitive: false, + enabled: true, + foreground: const Color(0xFF00FF00), + background: const Color(0xFFFF0000), + ); + final json = rule.toJson(); + final restored = AppKeywordHighlightRule.fromJson(json); + expect(restored.id, rule.id); + expect(restored.label, rule.label); + expect(restored.pattern, rule.pattern); + expect(restored.isRegex, rule.isRegex); + expect(restored.caseSensitive, rule.caseSensitive); + expect(restored.enabled, rule.enabled); + expect(restored.foreground?.value, rule.foreground?.value); + expect(restored.background?.value, rule.background?.value); + }); + + test('kDefaultKeywordHighlightRules all compile without error', () { + for (final rule in kDefaultKeywordHighlightRules) { + expect(rule.toXtermRule(), isNotNull, + reason: '${rule.label} pattern failed to compile'); + } + }); + + test('kDefaultKeywordHighlightRules contains expected labels', () { + final labels = kDefaultKeywordHighlightRules.map((r) => r.label).toSet(); + expect(labels, containsAll(['Error', 'Warning', 'Success', 'Done', 'OK', 'Debug', 'Info'])); + }); + }); +} From fc60e23670ce91c4a68f0c4657550f8957e5529f Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Sun, 7 Jun 2026 22:34:16 +0700 Subject: [PATCH 08/59] =?UTF-8?q?feat:=20SettingsProvider=20=E2=80=94=20ke?= =?UTF-8?q?ywordHighlightingEnabled=20+=20keywordHighlightRules=20persiste?= =?UTF-8?q?nce?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/lib/providers/settings_provider.dart | 22 ++++++++ app/test/settings_provider_test.dart | 69 ++++++++++++++++++++++++ 2 files changed, 91 insertions(+) diff --git a/app/lib/providers/settings_provider.dart b/app/lib/providers/settings_provider.dart index be1eeabb..b0255138 100644 --- a/app/lib/providers/settings_provider.dart +++ b/app/lib/providers/settings_provider.dart @@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart'; import 'package:path/path.dart' as p; import 'package:shared_preferences/shared_preferences.dart'; +import '../models/keyword_highlight_rule.dart'; import '../models/shell_profile.dart'; /// Default audit-log retention — single source for the provider, its @@ -50,6 +51,9 @@ class SettingsProvider extends ChangeNotifier { /// setDetectedShells, never persisted (ids are stable across launches). List detectedShellProfiles = []; + bool keywordHighlightingEnabled = true; + List keywordHighlightRules = kDefaultKeywordHighlightRules; + List get allShellProfiles => [...detectedShellProfiles, ...customShellProfiles]; @@ -121,6 +125,18 @@ class SettingsProvider extends ChangeNotifier { debugPrint('[SettingsProvider] hotkeys JSON malformed, using defaults: $e'); } } + keywordHighlightingEnabled = + prefs.getBool('keywordHighlightingEnabled') ?? true; + final rulesJson = prefs.getString('keywordHighlightRules'); + if (rulesJson != null) { + try { + keywordHighlightRules = (jsonDecode(rulesJson) as List) + .map((j) => AppKeywordHighlightRule.fromJson(j as Map)) + .toList(); + } catch (_) { + keywordHighlightRules = kDefaultKeywordHighlightRules; + } + } notifyListeners(); } @@ -150,6 +166,8 @@ class SettingsProvider extends ChangeNotifier { String? dashboardViewMode, String? dashboardSort, int? auditRetentionDays, + bool? keywordHighlightingEnabled, + List? keywordHighlightRules, }) async { if (autoReconnect != null) this.autoReconnect = autoReconnect; if (reconnectAttempts != null) this.reconnectAttempts = reconnectAttempts; @@ -170,6 +188,8 @@ class SettingsProvider extends ChangeNotifier { if (auditRetentionDays != null) { this.auditRetentionDays = auditRetentionDays; } + if (keywordHighlightingEnabled != null) this.keywordHighlightingEnabled = keywordHighlightingEnabled; + if (keywordHighlightRules != null) this.keywordHighlightRules = keywordHighlightRules; final prefs = await SharedPreferences.getInstance(); await prefs.setBool('autoReconnect', this.autoReconnect); await prefs.setInt('reconnectAttempts', this.reconnectAttempts); @@ -188,6 +208,8 @@ class SettingsProvider extends ChangeNotifier { await prefs.setString('dashboardViewMode', this.dashboardViewMode); await prefs.setString('dashboardSort', this.dashboardSort); await prefs.setInt('auditRetentionDays', this.auditRetentionDays); + await prefs.setBool('keywordHighlightingEnabled', this.keywordHighlightingEnabled); + await prefs.setString('keywordHighlightRules', jsonEncode(this.keywordHighlightRules.map((r) => r.toJson()).toList())); notifyListeners(); } diff --git a/app/test/settings_provider_test.dart b/app/test/settings_provider_test.dart index 4f4f814a..7552f03b 100644 --- a/app/test/settings_provider_test.dart +++ b/app/test/settings_provider_test.dart @@ -1,7 +1,9 @@ import 'dart:convert'; +import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:yourssh/models/keyword_highlight_rule.dart'; import 'package:yourssh/providers/settings_provider.dart'; void main() { @@ -166,4 +168,71 @@ void main() { expect(provider.dashboardViewMode, 'list'); expect(provider.dashboardSort, 'host_asc'); }); + + group('keyword highlighting', () { + test('keywordHighlightingEnabled defaults to true', () async { + final provider = SettingsProvider(); + await Future.delayed(Duration.zero); + expect(provider.keywordHighlightingEnabled, isTrue); + }); + + test('keywordHighlightRules defaults to kDefaultKeywordHighlightRules', () async { + final provider = SettingsProvider(); + await Future.delayed(Duration.zero); + expect(provider.keywordHighlightRules.length, + kDefaultKeywordHighlightRules.length); + }); + + test('save persists keywordHighlightingEnabled', () async { + final provider = SettingsProvider(); + await Future.delayed(Duration.zero); + await provider.save(keywordHighlightingEnabled: false); + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getBool('keywordHighlightingEnabled'), isFalse); + }); + + test('save persists keywordHighlightRules as JSON', () async { + final provider = SettingsProvider(); + await Future.delayed(Duration.zero); + final rule = AppKeywordHighlightRule( + id: 'x', + label: 'Test', + pattern: 'test', + isRegex: false, + caseSensitive: false, + enabled: true, + foreground: null, + background: const Color(0xFFFF0000), + ); + await provider.save(keywordHighlightRules: [rule]); + final prefs = await SharedPreferences.getInstance(); + final json = prefs.getString('keywordHighlightRules'); + expect(json, isNotNull); + final decoded = jsonDecode(json!) as List; + expect(decoded.length, 1); + expect(decoded[0]['id'], 'x'); + }); + + test('loads persisted keywordHighlightRules on init', () async { + final rule = AppKeywordHighlightRule( + id: 'y', + label: 'Loaded', + pattern: 'loaded', + isRegex: false, + caseSensitive: false, + enabled: true, + foreground: null, + background: null, + ); + SharedPreferences.setMockInitialValues({ + 'keywordHighlightRules': jsonEncode([rule.toJson()]), + 'keywordHighlightingEnabled': false, + }); + final provider = SettingsProvider(); + await Future.delayed(Duration.zero); + expect(provider.keywordHighlightRules.length, 1); + expect(provider.keywordHighlightRules[0].id, 'y'); + expect(provider.keywordHighlightingEnabled, isFalse); + }); + }); } From a7737577cb0df426c201b1889d8bb3bdfdd5277a Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Sun, 7 Jun 2026 22:36:17 +0700 Subject: [PATCH 09/59] feat: wire keyword highlight rules from SettingsProvider into TerminalView --- app/lib/widgets/terminal_view.dart | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/app/lib/widgets/terminal_view.dart b/app/lib/widgets/terminal_view.dart index dbf59d81..507112f3 100644 --- a/app/lib/widgets/terminal_view.dart +++ b/app/lib/widgets/terminal_view.dart @@ -430,6 +430,15 @@ class _TerminalWidgetState extends State<_TerminalWidget> { @override Widget build(BuildContext context) { final settings = context.watch(); + final keywordRules = context.select>( + (s) => s.keywordHighlightingEnabled + ? s.keywordHighlightRules + .where((r) => r.enabled) + .map((r) => r.toXtermRule()) + .whereType() + .toList() + : const [], + ); final appearance = _appearance(watch: true); final theme = terminalThemeByName(appearance.themeName); final showGutter = settings.shellIntegrationEnabled && @@ -449,6 +458,7 @@ class _TerminalWidgetState extends State<_TerminalWidget> { // Leave room for the gutter so it never occludes column-0 text. padding: showGutter ? const EdgeInsets.only(left: 10) : EdgeInsets.zero, autofocus: !_searchVisible, + keywordRules: keywordRules, onKeyEvent: _handleKey, onSecondaryTapUp: (details, _) => showTerminalContextMenu( context: context, From 28f4d28ab5db369f462284c6f8e3c70ba34c6f21 Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Sun, 7 Jun 2026 22:39:52 +0700 Subject: [PATCH 10/59] =?UTF-8?q?feat:=20keyword=20highlighting=20settings?= =?UTF-8?q?=20UI=20=E2=80=94=20rule=20list,=20add/edit=20dialog,=20color?= =?UTF-8?q?=20picker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../widgets/keyword_highlight_settings.dart | 516 ++++++++++++++++++ app/lib/widgets/settings_screen.dart | 5 + 2 files changed, 521 insertions(+) create mode 100644 app/lib/widgets/keyword_highlight_settings.dart diff --git a/app/lib/widgets/keyword_highlight_settings.dart b/app/lib/widgets/keyword_highlight_settings.dart new file mode 100644 index 00000000..206f5100 --- /dev/null +++ b/app/lib/widgets/keyword_highlight_settings.dart @@ -0,0 +1,516 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../models/keyword_highlight_rule.dart'; +import '../providers/settings_provider.dart'; +import '../theme/app_theme.dart'; + +const _kForegroundPresets = [ + Color(0xFFEF9A9A), + Color(0xFFFFCC80), + Color(0xFFFFF176), + Color(0xFFA5D6A7), + Color(0xFF80DEEA), + Color(0xFF90CAF9), + Color(0xFFCE93D8), + Color(0xFFFFFFFF), + Color(0xFF9E9E9E), + Color(0xFFBDBDBD), +]; + +const _kBackgroundPresets = [ + Color(0xFFB71C1C), + Color(0xFFE65100), + Color(0xFFF57F17), + Color(0xFF1B5E20), + Color(0xFF006064), + Color(0xFF0D47A1), + Color(0xFF4A148C), + Color(0xFF37474F), +]; + +class KeywordHighlightSection extends StatelessWidget { + const KeywordHighlightSection({super.key}); + + @override + Widget build(BuildContext context) { + final settings = context.watch(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SwitchListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 16), + title: const Text('Enable keyword highlighting', + style: TextStyle(color: AppColors.textPrimary, fontSize: 13)), + subtitle: const Text( + 'Tint matching text in all terminal sessions', + style: TextStyle(color: AppColors.textSecondary, fontSize: 11)), + value: settings.keywordHighlightingEnabled, + onChanged: (v) => context + .read() + .save(keywordHighlightingEnabled: v), + ), + const Divider(height: 1, color: AppColors.border, indent: 16), + ...settings.keywordHighlightRules.asMap().entries.map((entry) { + final i = entry.key; + final rule = entry.value; + return Column( + children: [ + _RuleRow( + rule: rule, + onToggle: (enabled) { + final updated = List.from( + settings.keywordHighlightRules); + updated[i] = rule.copyWith(enabled: enabled); + context + .read() + .save(keywordHighlightRules: updated); + }, + onEdit: () => _showRuleDialog(context, settings, rule: rule, index: i), + onDelete: () { + final updated = List.from( + settings.keywordHighlightRules) + ..removeAt(i); + context + .read() + .save(keywordHighlightRules: updated); + }, + ), + if (i < settings.keywordHighlightRules.length - 1) + const Divider(height: 1, color: AppColors.border, indent: 16), + ], + ); + }), + if (settings.keywordHighlightRules.length < kMaxKeywordHighlightRules) + Column( + children: [ + const Divider(height: 1, color: AppColors.border, indent: 16), + ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 16), + leading: const Icon(Icons.add, color: AppColors.accent, size: 18), + title: const Text('Add rule', + style: TextStyle(color: AppColors.accent, fontSize: 13)), + onTap: () => _showRuleDialog(context, settings), + ), + ], + ), + if (settings.keywordHighlightRules.length >= kMaxKeywordHighlightRules) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Text( + 'Maximum $kMaxKeywordHighlightRules rules reached.', + style: const TextStyle( + color: AppColors.textSecondary, fontSize: 11), + ), + ), + ], + ); + } + + Future _showRuleDialog( + BuildContext context, + SettingsProvider settings, { + AppKeywordHighlightRule? rule, + int? index, + }) async { + final result = await showDialog( + context: context, + builder: (_) => _KeywordRuleDialog(initial: rule), + ); + if (result == null || !context.mounted) return; + final updated = List.from(settings.keywordHighlightRules); + if (index != null) { + updated[index] = result; + } else { + updated.add(result); + } + context.read().save(keywordHighlightRules: updated); + } +} + +class _RuleRow extends StatelessWidget { + final AppKeywordHighlightRule rule; + final ValueChanged onToggle; + final VoidCallback onEdit; + final VoidCallback onDelete; + + const _RuleRow({ + required this.rule, + required this.onToggle, + required this.onEdit, + required this.onDelete, + }); + + @override + Widget build(BuildContext context) { + return ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 2), + leading: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (rule.background != null) + _ColorDot(color: rule.background!, label: 'bg'), + if (rule.foreground != null) ...[ + if (rule.background != null) const SizedBox(width: 4), + _ColorDot(color: rule.foreground!, label: 'fg', border: true), + ], + ], + ), + title: Text(rule.label, + style: const TextStyle(color: AppColors.textPrimary, fontSize: 13)), + subtitle: Text( + '${rule.isRegex ? "regex" : "literal"} · ${rule.pattern}', + style: const TextStyle(color: AppColors.textSecondary, fontSize: 11, fontFamily: 'monospace'), + overflow: TextOverflow.ellipsis, + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Switch( + value: rule.enabled, + onChanged: onToggle, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + IconButton( + icon: const Icon(Icons.edit_outlined, size: 16, color: AppColors.textSecondary), + onPressed: onEdit, + tooltip: 'Edit rule', + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 28, minHeight: 28), + ), + IconButton( + icon: const Icon(Icons.delete_outline, size: 16, color: AppColors.textSecondary), + onPressed: onDelete, + tooltip: 'Delete rule', + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 28, minHeight: 28), + ), + ], + ), + ); + } +} + +class _ColorDot extends StatelessWidget { + final Color color; + final String label; + final bool border; + + const _ColorDot({required this.color, required this.label, this.border = false}); + + @override + Widget build(BuildContext context) { + return Tooltip( + message: label, + child: Container( + width: 14, + height: 14, + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + border: border + ? Border.all(color: AppColors.textSecondary, width: 1.5) + : null, + ), + ), + ); + } +} + +class _KeywordRuleDialog extends StatefulWidget { + final AppKeywordHighlightRule? initial; + const _KeywordRuleDialog({this.initial}); + + @override + State<_KeywordRuleDialog> createState() => _KeywordRuleDialogState(); +} + +class _KeywordRuleDialogState extends State<_KeywordRuleDialog> { + late final TextEditingController _labelCtrl; + late final TextEditingController _patternCtrl; + late bool _isRegex; + late bool _caseSensitive; + Color? _foreground; + Color? _background; + String? _regexError; + + @override + void initState() { + super.initState(); + final r = widget.initial; + _labelCtrl = TextEditingController(text: r?.label ?? ''); + _patternCtrl = TextEditingController(text: r?.pattern ?? ''); + _isRegex = r?.isRegex ?? false; + _caseSensitive = r?.caseSensitive ?? false; + _foreground = r?.foreground; + _background = r?.background; + } + + @override + void dispose() { + _labelCtrl.dispose(); + _patternCtrl.dispose(); + super.dispose(); + } + + void _validatePattern() { + if (!_isRegex) { + setState(() => _regexError = null); + return; + } + try { + RegExp(_patternCtrl.text); + setState(() => _regexError = null); + } catch (e) { + setState(() => _regexError = e.toString()); + } + } + + bool get _isValid => + _labelCtrl.text.trim().isNotEmpty && + _patternCtrl.text.isNotEmpty && + _regexError == null && + (_foreground != null || _background != null); + + @override + Widget build(BuildContext context) { + return AlertDialog( + backgroundColor: AppColors.card, + title: Text( + widget.initial == null ? 'Add rule' : 'Edit rule', + style: const TextStyle(color: AppColors.textPrimary, fontSize: 15), + ), + content: SizedBox( + width: 380, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _field('Label', _labelCtrl, hint: 'e.g. Error'), + const SizedBox(height: 12), + _field( + 'Pattern', + _patternCtrl, + hint: _isRegex ? r'e.g. \berror\b' : 'e.g. error', + monospace: true, + onChanged: (_) => _validatePattern(), + errorText: _regexError, + ), + const SizedBox(height: 8), + Row( + children: [ + Checkbox( + value: _isRegex, + onChanged: (v) => + setState(() => _isRegex = v ?? false), + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + const Text('Regex', + style: TextStyle(color: AppColors.textPrimary, fontSize: 13)), + const SizedBox(width: 16), + Checkbox( + value: _caseSensitive, + onChanged: (v) => + setState(() => _caseSensitive = v ?? false), + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + const Text('Case-sensitive', + style: TextStyle(color: AppColors.textPrimary, fontSize: 13)), + ], + ), + const SizedBox(height: 12), + _ColorPickerButton( + label: 'Foreground (text color)', + current: _foreground, + presets: _kForegroundPresets, + onChanged: (c) => setState(() => _foreground = c), + ), + const SizedBox(height: 8), + _ColorPickerButton( + label: 'Background', + current: _background, + presets: _kBackgroundPresets, + onChanged: (c) => setState(() => _background = c), + ), + if (!_isValid && _labelCtrl.text.isNotEmpty && _patternCtrl.text.isNotEmpty) + const Padding( + padding: EdgeInsets.only(top: 8), + child: Text( + 'Choose at least one color.', + style: TextStyle(color: Colors.red, fontSize: 11), + ), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: _isValid + ? () { + Navigator.pop( + context, + AppKeywordHighlightRule( + id: widget.initial?.id, + label: _labelCtrl.text.trim(), + pattern: _patternCtrl.text, + isRegex: _isRegex, + caseSensitive: _caseSensitive, + enabled: widget.initial?.enabled ?? true, + foreground: _foreground, + background: _background, + ), + ); + } + : null, + child: const Text('Save'), + ), + ], + ); + } + + Widget _field( + String label, + TextEditingController ctrl, { + String? hint, + bool monospace = false, + ValueChanged? onChanged, + String? errorText, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, + style: const TextStyle( + color: AppColors.textSecondary, fontSize: 11, fontWeight: FontWeight.w600)), + const SizedBox(height: 4), + TextField( + controller: ctrl, + style: TextStyle( + color: AppColors.textPrimary, + fontSize: 13, + fontFamily: monospace ? 'monospace' : null, + ), + decoration: InputDecoration( + hintText: hint, + hintStyle: const TextStyle(color: AppColors.textTertiary, fontSize: 12), + errorText: errorText, + isDense: true, + contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + border: const OutlineInputBorder(), + ), + onChanged: onChanged, + ), + ], + ); + } +} + +class _ColorPickerButton extends StatelessWidget { + final String label; + final Color? current; + final List presets; + final ValueChanged onChanged; + + const _ColorPickerButton({ + required this.label, + required this.current, + required this.presets, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded( + child: Text(label, + style: const TextStyle(color: AppColors.textSecondary, fontSize: 12)), + ), + GestureDetector( + onTap: () => _pick(context), + child: Container( + width: 80, + height: 28, + decoration: BoxDecoration( + color: current ?? Colors.transparent, + border: Border.all(color: AppColors.border), + borderRadius: BorderRadius.circular(4), + ), + alignment: Alignment.center, + child: current == null + ? const Text('None', + style: TextStyle(color: AppColors.textSecondary, fontSize: 11)) + : null, + ), + ), + ], + ); + } + + Future _pick(BuildContext context) async { + final result = await showDialog( + context: context, + builder: (_) => _ColorGridDialog(presets: presets, current: current), + ); + if (result == _clearSentinel) { + onChanged(null); + } else if (result != null) { + onChanged(result); + } + } +} + +final _clearSentinel = const Color(0x00000000); + +class _ColorGridDialog extends StatelessWidget { + final List presets; + final Color? current; + const _ColorGridDialog({required this.presets, required this.current}); + + @override + Widget build(BuildContext context) { + return AlertDialog( + backgroundColor: AppColors.card, + title: const Text('Pick color', + style: TextStyle(color: AppColors.textPrimary, fontSize: 14)), + content: Wrap( + spacing: 8, + runSpacing: 8, + children: [ + GestureDetector( + onTap: () => Navigator.pop(context, _clearSentinel), + child: Container( + width: 32, + height: 32, + decoration: BoxDecoration( + border: Border.all(color: AppColors.border), + borderRadius: BorderRadius.circular(4), + ), + alignment: Alignment.center, + child: const Text('✕', + style: TextStyle(color: AppColors.textSecondary, fontSize: 12)), + ), + ), + ...presets.map((c) => GestureDetector( + onTap: () => Navigator.pop(context, c), + child: Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: c, + borderRadius: BorderRadius.circular(4), + border: current == c + ? Border.all(color: Colors.white, width: 2) + : null, + ), + ), + )), + ], + ), + ); + } +} diff --git a/app/lib/widgets/settings_screen.dart b/app/lib/widgets/settings_screen.dart index 65786acf..41ecc5ea 100644 --- a/app/lib/widgets/settings_screen.dart +++ b/app/lib/widgets/settings_screen.dart @@ -17,6 +17,7 @@ import 'package:url_launcher/url_launcher.dart'; import '../theme/app_theme.dart'; import 'hotkey_settings_screen.dart'; import 'terminal_appearance_controls.dart'; +import 'keyword_highlight_settings.dart'; import 'confirm_dialog.dart'; import 'qr_export_dialog.dart'; import 'qr_import_dialog.dart'; @@ -148,6 +149,10 @@ class _SettingsScreenState extends State { const TerminalAppearanceControls(layout: AppearanceControlsLayout.rows), ]), const SizedBox(height: 24), + _Section(title: 'Keyword Highlighting', children: [ + const KeywordHighlightSection(), + ]), + const SizedBox(height: 24), _Section(title: 'Recording', children: [ Consumer( builder: (context, settings, _) => _Row( From 01e617ddf3b1e8a8428f22d3e12df9ea696982b2 Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Sun, 7 Jun 2026 22:42:24 +0700 Subject: [PATCH 11/59] =?UTF-8?q?feat:=20terminal=20config=20panel=20?= =?UTF-8?q?=E2=80=94=20compact=20keyword=20highlight=20toggle=20section?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/lib/widgets/terminal_config_panel.dart | 128 ++++++++++++++++++++- 1 file changed, 123 insertions(+), 5 deletions(-) diff --git a/app/lib/widgets/terminal_config_panel.dart b/app/lib/widgets/terminal_config_panel.dart index 6ddb9131..19a679f3 100644 --- a/app/lib/widgets/terminal_config_panel.dart +++ b/app/lib/widgets/terminal_config_panel.dart @@ -1,13 +1,16 @@ -// app/lib/widgets/terminal_config_panel.dart import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../models/keyword_highlight_rule.dart'; +import '../providers/settings_provider.dart'; +import '../theme/app_theme.dart'; import 'terminal_appearance_controls.dart'; import 'workspace_side_panel.dart'; -/// Right-side workspace panel for terminal appearance settings. class TerminalConfigPanel extends StatelessWidget { final VoidCallback? onClose; + final VoidCallback? onOpenSettings; - const TerminalConfigPanel({super.key, this.onClose}); + const TerminalConfigPanel({super.key, this.onClose, this.onOpenSettings}); @override Widget build(BuildContext context) { @@ -17,12 +20,127 @@ class TerminalConfigPanel extends StatelessWidget { onClose: onClose, child: ListView( padding: const EdgeInsets.all(16), - children: const [ - TerminalAppearanceControls( + children: [ + const TerminalAppearanceControls( layout: AppearanceControlsLayout.vertical, ), + const SizedBox(height: 20), + _KeywordHighlightCompact(onOpenSettings: onOpenSettings), ], ), ); } } + +class _KeywordHighlightCompact extends StatelessWidget { + final VoidCallback? onOpenSettings; + const _KeywordHighlightCompact({this.onOpenSettings}); + + @override + Widget build(BuildContext context) { + final settings = context.watch(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'KEYWORD HIGHLIGHTING', + style: TextStyle( + color: AppColors.textSecondary, + fontSize: 11, + fontWeight: FontWeight.w600, + letterSpacing: 0.5, + ), + ), + const SizedBox(height: 8), + Row( + children: [ + const Expanded( + child: Text('Enable', + style: TextStyle( + color: AppColors.textPrimary, fontSize: 13)), + ), + Switch( + value: settings.keywordHighlightingEnabled, + onChanged: (v) => context + .read() + .save(keywordHighlightingEnabled: v), + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + ], + ), + const SizedBox(height: 4), + ...settings.keywordHighlightRules.asMap().entries.map((entry) { + final i = entry.key; + final rule = entry.value; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + children: [ + if (rule.background != null) + _Dot(color: rule.background!), + if (rule.foreground != null) ...[ + if (rule.background != null) const SizedBox(width: 4), + _Dot(color: rule.foreground!, border: true), + ], + const SizedBox(width: 8), + Expanded( + child: Text( + rule.label, + style: const TextStyle( + color: AppColors.textPrimary, fontSize: 12), + ), + ), + Switch( + value: rule.enabled, + onChanged: (v) { + final updated = List.from( + settings.keywordHighlightRules); + updated[i] = rule.copyWith(enabled: v); + context + .read() + .save(keywordHighlightRules: updated); + }, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + ], + ), + ); + }), + const SizedBox(height: 8), + if (onOpenSettings != null) + GestureDetector( + onTap: onOpenSettings, + child: const Text( + 'Manage rules in Settings →', + style: TextStyle( + color: AppColors.accent, + fontSize: 12, + decoration: TextDecoration.underline), + ), + ), + ], + ); + } +} + +class _Dot extends StatelessWidget { + final Color color; + final bool border; + const _Dot({required this.color, this.border = false}); + + @override + Widget build(BuildContext context) { + return Container( + width: 12, + height: 12, + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + border: border + ? Border.all(color: AppColors.textSecondary, width: 1) + : null, + ), + ); + } +} From 21d2656be5297c8c8eb6c214f594d539175f0707 Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Sun, 7 Jun 2026 22:48:30 +0700 Subject: [PATCH 12/59] fix(keyword-highlight): semi-transparent default bg colors, lineY truncation, Color.toARGB32 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Error/Fail/Warning/Warn default backgrounds: 0xFF→0xCC (80% opacity) so text stays legible under the background rect (opaque colors painted post-glyph would cover the matched text entirely) - _paintKeywordHighlights lineY: add .truncateToDouble() to match paintLine's coordinate rounding and avoid sub-pixel vertical misalignment - Color serialization: foreground?.value → toARGB32() to silence deprecation --- app/lib/models/keyword_highlight_rule.dart | 12 ++++++------ app/test/models/keyword_highlight_rule_test.dart | 4 ++-- packages/xterm/lib/src/ui/render.dart | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/app/lib/models/keyword_highlight_rule.dart b/app/lib/models/keyword_highlight_rule.dart index 89be9e55..0f4bc1ea 100644 --- a/app/lib/models/keyword_highlight_rule.dart +++ b/app/lib/models/keyword_highlight_rule.dart @@ -44,8 +44,8 @@ class AppKeywordHighlightRule { 'isRegex': isRegex, 'caseSensitive': caseSensitive, 'enabled': enabled, - 'foreground': foreground?.value, - 'background': background?.value, + 'foreground': foreground?.toARGB32(), + 'background': background?.toARGB32(), }; factory AppKeywordHighlightRule.fromJson(Map json) { @@ -104,7 +104,7 @@ final kDefaultKeywordHighlightRules = [ caseSensitive: false, enabled: true, foreground: null, - background: const Color(0xFFB71C1C), + background: const Color(0xCCB71C1C), ), AppKeywordHighlightRule( id: 'default_fail', @@ -114,7 +114,7 @@ final kDefaultKeywordHighlightRules = [ caseSensitive: false, enabled: true, foreground: null, - background: const Color(0xFFB71C1C), + background: const Color(0xCCB71C1C), ), AppKeywordHighlightRule( id: 'default_warning', @@ -124,7 +124,7 @@ final kDefaultKeywordHighlightRules = [ caseSensitive: false, enabled: true, foreground: null, - background: const Color(0xFFE65100), + background: const Color(0xCCE65100), ), AppKeywordHighlightRule( id: 'default_warn', @@ -134,7 +134,7 @@ final kDefaultKeywordHighlightRules = [ caseSensitive: false, enabled: true, foreground: null, - background: const Color(0xFFE65100), + background: const Color(0xCCE65100), ), AppKeywordHighlightRule( id: 'default_success', diff --git a/app/test/models/keyword_highlight_rule_test.dart b/app/test/models/keyword_highlight_rule_test.dart index c6069c85..382ea33a 100644 --- a/app/test/models/keyword_highlight_rule_test.dart +++ b/app/test/models/keyword_highlight_rule_test.dart @@ -88,8 +88,8 @@ void main() { expect(restored.isRegex, rule.isRegex); expect(restored.caseSensitive, rule.caseSensitive); expect(restored.enabled, rule.enabled); - expect(restored.foreground?.value, rule.foreground?.value); - expect(restored.background?.value, rule.background?.value); + expect(restored.foreground?.toARGB32(), rule.foreground?.toARGB32()); + expect(restored.background?.toARGB32(), rule.background?.toARGB32()); }); test('kDefaultKeywordHighlightRules all compile without error', () { diff --git a/packages/xterm/lib/src/ui/render.dart b/packages/xterm/lib/src/ui/render.dart index 708f23e1..b2339d9c 100644 --- a/packages/xterm/lib/src/ui/render.dart +++ b/packages/xterm/lib/src/ui/render.dart @@ -568,7 +568,7 @@ class RenderTerminal extends RenderBox with RelayoutWhenSystemFontsChangeMixin { for (var i = firstLine; i <= lastLine; i++) { if (i >= lines.length) break; final lineText = lines[i].getText(); - final lineY = i * charHeight + _lineOffset; + final lineY = (i * charHeight + _lineOffset).truncateToDouble(); for (final rule in _keywordRules) { for (final m in rule.pattern.allMatches(lineText)) { From f7b096e3c518cc0c505d5d83cd0891568ed3c80d Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Mon, 8 Jun 2026 08:10:10 +0700 Subject: [PATCH 13/59] docs: server monitor panel design spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-host live monitoring panel — CPU/mem/disk/uptime/ports/firewall via draggable bottom sheet on the Hosts Dashboard. Covers models, two polling services (SystemStatsService 5s, FirewallStatusService 30s), UI layout, error handling, and test plan. --- .../2026-06-08-server-monitor-panel-design.md | 218 ++++++++++++++++++ 1 file changed, 218 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-08-server-monitor-panel-design.md diff --git a/docs/superpowers/specs/2026-06-08-server-monitor-panel-design.md b/docs/superpowers/specs/2026-06-08-server-monitor-panel-design.md new file mode 100644 index 00000000..611b3b2e --- /dev/null +++ b/docs/superpowers/specs/2026-06-08-server-monitor-panel-design.md @@ -0,0 +1,218 @@ +# Server Monitor Panel — Design + +**Date:** 2026-06-08 +**Status:** Approved + +## Overview + +Add a per-host live monitoring panel to the Hosts Dashboard. When a host has an active SSH session, the user can open a draggable bottom sheet showing real-time CPU, memory, disk, uptime, open ports, and firewall status. The topology graph is out of scope for this iteration. + +## Scope + +**In:** +- Real-time CPU / memory / disk / uptime (5s polling) +- Open ports via `ss -tulpn` / `netstat -tulpn` (5s polling, same exec as system stats) +- Firewall status + rules (`ufw` / `iptables` / `nftables`, 30s polling) +- Entry point: new icon button on connected host cards + context menu item +- Linux hosts only; non-Linux hosts show "Unavailable" per section + +**Out:** +- Network topology graph (future feature) +- Write operations (firewall rule add/remove, port close) +- macOS / Windows remote host support +- Hosts without an active SSH session (shown as "not connected" placeholder) + +## Models + +### `app/lib/models/system_snapshot.dart` + +```dart +class SystemSnapshot { + final double cpuPercent; // 0.0–100.0 + final int totalMemBytes; + final int usedMemBytes; + final List disks; + final Duration uptime; + final List ports; + final DateTime timestamp; + + // Pure factory: shell output string → model. Zero I/O. + static SystemSnapshot fromShellOutput(String output) { ... } +} + +class DiskMount { + final String source; // e.g. /dev/sda1 + final String mountPoint; // e.g. / + final int totalKb; + final int usedKb; +} + +class PortEntry { + final String protocol; // "tcp" | "udp" + final String localAddress; + final int localPort; + final String? process; // null if no sudo or not reported +} +``` + +CPU percent is computed from two `/proc/stat` reads 200ms apart within the same exec (avoids a second SSH round-trip). + +### `app/lib/models/firewall_status.dart` + +```dart +enum FirewallType { ufw, iptables, nftables, none } + +class FirewallStatus { + final FirewallType type; + final bool enabled; + final String? defaultInboundPolicy; // "ACCEPT" | "DROP" | "REJECT" | null + final List rules; + + // Pure factory: shell output string → model. + static FirewallStatus fromShellOutput(String output) { ... } +} + +class FirewallRule { + final String description; // formatted display line + final String? action; // "ALLOW" | "DENY" | "ACCEPT" | "DROP" + final String? chain; // iptables chain name; null for ufw +} +``` + +## Services + +Both services follow the `NetworkStatsService` pattern exactly: `Timer.periodic` → `SshService.exec` → parse → callback. `auditSource: null` on every exec to avoid flooding the audit log. + +### `app/lib/services/system_stats_service.dart` + +```dart +class SystemStatsService { + final Host host; + final SshService sshService; + final void Function(SystemSnapshot) onUpdate; + + void start({Duration interval = const Duration(seconds: 5)}); + void stop(); +} +``` + +Single compound shell command per poll: + +```sh +c1=$(awk '/^cpu /{print}' /proc/stat); sleep 0.2; c2=$(awk '/^cpu /{print}' /proc/stat) +printf '__CPU1__\n%s\n__CPU2__\n%s\n' "$c1" "$c2" +printf '__MEM__\n'; cat /proc/meminfo +printf '__DISK__\n'; df -k +printf '__UPTIME__\n'; cat /proc/uptime +printf '__PORTS__\n'; ss -tulpn 2>/dev/null || netstat -tulpn 2>/dev/null +``` + +Exec errors (session mid-reconnect, command not found) are silently ignored; the UI holds the last known snapshot. + +### `app/lib/services/firewall_status_service.dart` + +```dart +class FirewallStatusService { + final Host host; + final SshService sshService; + final void Function(FirewallStatus) onUpdate; + + void start({Duration interval = const Duration(seconds: 30)}); + void stop(); +} +``` + +Single exec per poll: + +```sh +ufw status numbered 2>/dev/null \ + || iptables-save 2>/dev/null \ + || nft list ruleset 2>/dev/null \ + || echo '__NO_FIREWALL__' +``` + +Both services are instantiated inside `ServerMonitorSheet.initState` and stopped in `dispose`. No global provider needed — state is ephemeral and per-sheet. + +## UI + +### Entry point + +A `Icons.monitor_heart` icon button added to the host card action row — visible only when `SessionProvider.sshSessions.containsKey(host.id)` (no point surfacing it when clearly offline). The context menu always shows a "Monitor" item (even for disconnected hosts) so the feature is discoverable; the sheet handles the "not connected" placeholder state when no session is active. + +### `ServerMonitorSheet` + +`showModalBottomSheet` wrapping a `DraggableScrollableSheet` (initialChildSize: 0.6, min: 0.4, max: 0.95). + +``` +┌─────────────────────────────────────┐ +│ ● ubuntu-prod [Linux] ◉ Live │ ← header +├─────────────────────────────────────┤ +│ SYSTEM │ +│ Uptime 14d 3h 22m │ +│ CPU ████████░░ 82.4% │ +│ Memory ██████░░░░ 3.1 / 8.0 GB │ +│ / ████░░░░░░ 45% of 120GB │ +│ /boot ██░░░░░░░░ 18% of 512MB │ +├─────────────────────────────────────┤ +│ PORTS │ +│ tcp 0.0.0.0:22 sshd │ +│ tcp 0.0.0.0:80 nginx │ +│ udp 127.0.0.1:53 systemd-resolve │ +├─────────────────────────────────────┤ +│ FIREWALL [ufw • active] │ +│ Default inbound: DENY │ +│ 22/tcp ALLOW anywhere │ +│ 80/tcp ALLOW anywhere │ +│ 443/tcp ALLOW anywhere │ +└─────────────────────────────────────┘ +``` + +### States + +| State | Behavior | +|---|---| +| Host not connected | Centered message: "No active session — open a terminal first" | +| Connected, awaiting first poll | `CircularProgressIndicator` per section | +| Section exec failed | Grey "Unavailable" chip with reason | +| Firewall type `none` | Grey "No firewall detected" in firewall section | +| `ufw`/`iptables` requires sudo | "Firewall detection unavailable (may require sudo)" note | + +### No new global provider + +`SystemStatsService` and `FirewallStatusService` are owned by `_ServerMonitorSheetState`. Data flows via `setState`. This avoids polluting the global provider tree with per-host ephemeral monitoring state. + +## Error handling + +- Per-field parse failures produce `null` or `0` values — never throw. One bad field does not blank the section. +- Unknown firewall output → `FirewallType.none`, empty rules list. +- `df` rows missing mount point → skipped. +- `ss`/`netstat` lines that fail to parse → skipped individually. +- Exec errors during polling → silently ignored; UI holds last snapshot. + +## Testing + +| File | What it covers | +|---|---| +| `test/models/system_snapshot_test.dart` | Parser with `/proc/stat`, `/proc/meminfo`, `df -k`, `ss -tulpn` fixtures | +| `test/models/firewall_status_test.dart` | Parser with ufw / iptables-save / nft / unknown fixtures | +| `test/services/system_stats_service_test.dart` | Timer fires → exec called → `onUpdate` receives snapshot (mock `SshService`) | +| `test/services/firewall_status_service_test.dart` | Same shape | +| `test/widgets/server_monitor_sheet_test.dart` | Renders all sections from fixed snapshot; "not connected" state | + +Parser tests are the highest-value layer — pure functions, real distro fixture strings, no mocking. + +## File checklist + +``` +app/lib/models/system_snapshot.dart (new) +app/lib/models/firewall_status.dart (new) +app/lib/services/system_stats_service.dart (new) +app/lib/services/firewall_status_service.dart (new) +app/lib/widgets/server_monitor_sheet.dart (new) +app/lib/widgets/hosts_dashboard.dart (edit — add monitor button + context menu item) +app/test/models/system_snapshot_test.dart (new) +app/test/models/firewall_status_test.dart (new) +app/test/services/system_stats_service_test.dart (new) +app/test/services/firewall_status_service_test.dart (new) +app/test/widgets/server_monitor_sheet_test.dart (new) +``` From 41d25decc118e8bc057425f48cfa3802fdda06e4 Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Mon, 8 Jun 2026 08:20:10 +0700 Subject: [PATCH 14/59] docs: server monitor panel implementation plan --- .../plans/2026-06-08-server-monitor-panel.md | 1524 +++++++++++++++++ 1 file changed, 1524 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-08-server-monitor-panel.md diff --git a/docs/superpowers/plans/2026-06-08-server-monitor-panel.md b/docs/superpowers/plans/2026-06-08-server-monitor-panel.md new file mode 100644 index 00000000..5a027991 --- /dev/null +++ b/docs/superpowers/plans/2026-06-08-server-monitor-panel.md @@ -0,0 +1,1524 @@ +# Server Monitor Panel Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a live per-host monitoring panel to the Hosts Dashboard showing CPU, memory, disk, uptime, open ports, and firewall status via a draggable bottom sheet. + +**Architecture:** Two polling services (`SystemStatsService` at 5s, `FirewallStatusService` at 30s) follow the `NetworkStatsService` pattern exactly — `Timer.periodic` → `SshService.exec` → pure parser → callback. The `ServerMonitorSheet` owns the services in its `State`, starts them in `didChangeDependencies`, and stops them in `dispose`. A "Monitor" button on connected host cards plus a context menu item in `HostsDashboard` open the sheet. + +**Tech Stack:** Flutter/Dart, `SshService.exec` (existing), `Timer.periodic`, `DraggableScrollableSheet`, `LinearProgressIndicator`, provider pattern. + +--- + +## File Map + +``` +app/lib/models/system_snapshot.dart (new) +app/lib/models/firewall_status.dart (new) +app/lib/services/system_stats_service.dart (new) +app/lib/services/firewall_status_service.dart (new) +app/lib/widgets/server_monitor_sheet.dart (new) +app/lib/widgets/hosts_dashboard.dart (edit — monitor button + context menu) +app/test/models/system_snapshot_test.dart (new) +app/test/models/firewall_status_test.dart (new) +app/test/services/system_stats_service_test.dart (new) +app/test/services/firewall_status_service_test.dart (new) +app/test/widgets/server_monitor_sheet_test.dart (new) +``` + +--- + +## Task 1: SystemSnapshot model + parser + +**Files:** +- Create: `app/lib/models/system_snapshot.dart` +- Create: `app/test/models/system_snapshot_test.dart` + +- [ ] **Step 1: Write the failing test** + +```dart +// app/test/models/system_snapshot_test.dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:yourssh/models/system_snapshot.dart'; + +const _kFullOutput = ''' +__CPU1__ +cpu 454542 3253 96425 15678532 5432 0 1234 0 0 0 +__CPU2__ +cpu 454643 3253 96431 15679102 5435 0 1235 0 0 0 +__MEM__ +MemTotal: 16384000 kB +MemFree: 8192000 kB +MemAvailable: 9216000 kB +Buffers: 512000 kB +Cached: 1024000 kB +__DISK__ +Filesystem 1K-blocks Used Available Use% Mounted on +/dev/sda1 120000000 54000000 66000000 45% / +/dev/sdb1 20000000 4000000 16000000 20% /data +tmpfs 8192000 0 8192000 0% /dev/shm +devtmpfs 4096000 0 4096000 0% /dev +__UPTIME__ +1234567.89 432.10 +__PORTS__ +Netid State Recv-Q Send-Q Local Address:Port Peer Address:Port Process +tcp LISTEN 0 128 0.0.0.0:22 0.0.0.0:* users:(("sshd",pid=1234,fd=3)) +tcp LISTEN 0 128 0.0.0.0:80 0.0.0.0:* users:(("nginx",pid=5678,fd=6)) +tcp6 LISTEN 0 128 [::]:22 [::]:* users:(("sshd",pid=1234,fd=3)) +udp UNCONN 0 0 127.0.0.53%lo:53 0.0.0.0:* users:(("systemd-resolve",pid=567,fd=12)) +'''; + +void main() { + group('SystemSnapshot.fromShellOutput', () { + test('parses cpu percent from two proc/stat reads', () { + final s = SystemSnapshot.fromShellOutput(_kFullOutput); + // totalDelta=681, idleDelta=573 → (1 - 573/681)*100 ≈ 15.86% + expect(s.cpuPercent, closeTo(15.86, 0.5)); + }); + + test('parses memory', () { + final s = SystemSnapshot.fromShellOutput(_kFullOutput); + expect(s.totalMemBytes, 16384000 * 1024); + expect(s.usedMemBytes, (16384000 - 9216000) * 1024); // 7168000 * 1024 + }); + + test('parses disks and skips tmpfs/devtmpfs', () { + final s = SystemSnapshot.fromShellOutput(_kFullOutput); + expect(s.disks.length, 2); + expect(s.disks.map((d) => d.mountPoint), containsAll(['/', '/data'])); + expect(s.disks.any((d) => d.source.startsWith('tmpfs')), isFalse); + }); + + test('parses uptime', () { + final s = SystemSnapshot.fromShellOutput(_kFullOutput); + expect(s.uptime, const Duration(seconds: 1234567)); + }); + + test('parses ports and deduplicates tcp/tcp6', () { + final s = SystemSnapshot.fromShellOutput(_kFullOutput); + // port 22 appears as tcp + tcp6 → deduplicated to 1 + expect(s.ports.length, 3); // 22 (deduped), 80, 53 + expect(s.ports.map((p) => p.localPort), containsAll([22, 80, 53])); + final ssh = s.ports.firstWhere((p) => p.localPort == 22); + expect(ssh.protocol, 'tcp'); + expect(ssh.process, 'sshd'); + }); + + test('returns zeroes on empty output', () { + final s = SystemSnapshot.fromShellOutput(''); + expect(s.cpuPercent, 0.0); + expect(s.totalMemBytes, 0); + expect(s.disks, isEmpty); + expect(s.ports, isEmpty); + }); + + test('parses netstat -tulpn port format', () { + const output = ''' +__CPU1__ +cpu 100 0 0 900 0 0 0 0 0 0 +__CPU2__ +cpu 101 0 0 901 0 0 0 0 0 0 +__MEM__ +MemTotal: 1024 kB +MemAvailable: 512 kB +__DISK__ +Filesystem 1K-blocks Used Available Use% Mounted on +/dev/sda1 100000 50000 50000 50% / +__UPTIME__ +100.0 50.0 +__PORTS__ +Proto Recv-Q Send-Q Local Address Foreign Address State PID/Program name +tcp 0 0 0.0.0.0:22 0.0.0.0:* LISTEN 1234/sshd +tcp6 0 0 :::22 :::* LISTEN 1234/sshd +'''; + final s = SystemSnapshot.fromShellOutput(output); + expect(s.ports.length, 1); // deduplicated + expect(s.ports.first.localPort, 22); + }); + }); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +cd app && flutter test test/models/system_snapshot_test.dart +``` + +Expected: `Error: 'system_snapshot.dart' not found` or similar compile error. + +- [ ] **Step 3: Implement `system_snapshot.dart`** + +```dart +// app/lib/models/system_snapshot.dart +class SystemSnapshot { + final double cpuPercent; + final int totalMemBytes; + final int usedMemBytes; + final List disks; + final Duration uptime; + final List ports; + final DateTime timestamp; + + const SystemSnapshot({ + required this.cpuPercent, + required this.totalMemBytes, + required this.usedMemBytes, + required this.disks, + required this.uptime, + required this.ports, + required this.timestamp, + }); + + factory SystemSnapshot.fromShellOutput(String output) { + final sections = _splitSections(output); + return SystemSnapshot( + cpuPercent: _parseCpuPercent( + sections['__CPU1__'] ?? '', + sections['__CPU2__'] ?? '', + ), + totalMemBytes: _parseMem(sections['__MEM__'] ?? '').$1, + usedMemBytes: _parseMem(sections['__MEM__'] ?? '').$2, + disks: _parseDisks(sections['__DISK__'] ?? ''), + uptime: _parseUptime(sections['__UPTIME__'] ?? ''), + ports: _parsePorts(sections['__PORTS__'] ?? ''), + timestamp: DateTime.now(), + ); + } + + static Map _splitSections(String output) { + const sentinels = { + '__CPU1__', '__CPU2__', '__MEM__', '__DISK__', '__UPTIME__', '__PORTS__', + }; + final sections = {}; + String? currentKey; + final buf = StringBuffer(); + for (final line in output.split('\n')) { + final t = line.trim(); + if (sentinels.contains(t)) { + if (currentKey != null) sections[currentKey] = buf.toString(); + currentKey = t; + buf.clear(); + } else if (currentKey != null) { + buf.writeln(line); + } + } + if (currentKey != null) sections[currentKey] = buf.toString(); + return sections; + } + + static double _parseCpuPercent(String cpu1, String cpu2) { + final s1 = _cpuStats(cpu1.trim()); + final s2 = _cpuStats(cpu2.trim()); + if (s1 == null || s2 == null || s1.length < 5 || s2.length < 5) return 0.0; + final total1 = s1.reduce((a, b) => a + b); + final idle1 = s1[3] + s1[4]; // idle + iowait + final total2 = s2.reduce((a, b) => a + b); + final idle2 = s2[3] + s2[4]; + final dTotal = total2 - total1; + final dIdle = idle2 - idle1; + if (dTotal <= 0) return 0.0; + return ((1.0 - dIdle / dTotal) * 100.0).clamp(0.0, 100.0); + } + + static List? _cpuStats(String line) { + final parts = line.split(RegExp(r'\s+')); + if (parts.isEmpty || !parts[0].startsWith('cpu')) return null; + return parts.skip(1).map((s) => int.tryParse(s) ?? 0).toList(); + } + + static (int, int) _parseMem(String section) { + int totalKb = 0, availableKb = 0; + for (final line in section.split('\n')) { + final parts = line.trim().split(RegExp(r'\s+')); + if (parts.length < 2) continue; + final val = int.tryParse(parts[1]) ?? 0; + if (parts[0] == 'MemTotal:') totalKb = val; + if (parts[0] == 'MemAvailable:') availableKb = val; + } + final total = totalKb * 1024; + final used = ((totalKb - availableKb) * 1024).clamp(0, total); + return (total, used); + } + + static const _kSkipFs = { + 'tmpfs', 'devtmpfs', 'overlay', 'squashfs', 'udev', 'run', 'none', + }; + + static List _parseDisks(String section) { + final result = []; + final lines = section.split('\n').skip(1); // skip header + for (final line in lines) { + final parts = line.trim().split(RegExp(r'\s+')); + if (parts.length < 6) continue; + final source = parts[0]; + if (_kSkipFs.any((f) => source.startsWith(f))) continue; + final totalKb = int.tryParse(parts[1]) ?? 0; + final usedKb = int.tryParse(parts[2]) ?? 0; + final mount = parts[5]; + result.add(DiskMount(source: source, mountPoint: mount, totalKb: totalKb, usedKb: usedKb)); + } + return result; + } + + static Duration _parseUptime(String section) { + final line = section.trim().split('\n').first.trim(); + final secs = double.tryParse(line.split(' ').first) ?? 0.0; + return Duration(seconds: secs.floor()); + } + + static List _parsePorts(String section) { + final entries = []; + for (final line in section.split('\n')) { + final parts = line.trim().split(RegExp(r'\s+')); + if (parts.length < 5) continue; + final proto = parts[0].toLowerCase(); + if (!proto.startsWith('tcp') && !proto.startsWith('udp')) continue; + + String? localAddr; + String? processStr; + + // ss format: State is parts[1] (LISTEN/UNCONN), local addr is parts[4] + if (parts[1] == 'LISTEN' || parts[1] == 'UNCONN') { + localAddr = parts[4]; + processStr = parts.length > 6 ? parts.skip(6).join(' ') : null; + } + // netstat format: State is parts[5], local addr is parts[3] + else if (parts.length >= 6 && parts[5] == 'LISTEN') { + localAddr = parts[3]; + processStr = parts.length > 6 ? parts[6] : null; + } + + if (localAddr == null) continue; + final lastColon = localAddr.lastIndexOf(':'); + if (lastColon < 0) continue; + final port = int.tryParse(localAddr.substring(lastColon + 1)); + if (port == null) continue; + final address = localAddr.substring(0, lastColon); + + entries.add(PortEntry( + protocol: proto.replaceAll('6', '').replaceAll('4', ''), + localAddress: address, + localPort: port, + process: _extractProcess(processStr), + )); + } + + // Deduplicate by port number, sort ascending + final seen = {}; + final deduped = entries.where((e) => seen.add(e.localPort)).toList() + ..sort((a, b) => a.localPort.compareTo(b.localPort)); + return deduped; + } + + static String? _extractProcess(String? raw) { + if (raw == null) return null; + // ss: users:(("sshd",pid=1234,fd=3)) or netstat: 1234/sshd + final ssMatch = RegExp(r'"([^"]+)"').firstMatch(raw); + if (ssMatch != null) return ssMatch.group(1); + final parts = raw.split('/'); + return parts.length > 1 ? parts.last.trim() : null; + } + + static String formatBytes(int bytes) { + if (bytes < 1024) return '$bytes B'; + if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; + if (bytes < 1024 * 1024 * 1024) return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; + return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB'; + } + + static String formatUptime(Duration d) { + final days = d.inDays; + final hours = d.inHours % 24; + final minutes = d.inMinutes % 60; + final parts = []; + if (days > 0) parts.add('${days}d'); + if (hours > 0) parts.add('${hours}h'); + parts.add('${minutes}m'); + return parts.join(' '); + } +} + +class DiskMount { + final String source; + final String mountPoint; + final int totalKb; + final int usedKb; + const DiskMount({ + required this.source, + required this.mountPoint, + required this.totalKb, + required this.usedKb, + }); + double get usedPercent => totalKb == 0 ? 0 : usedKb / totalKb; +} + +class PortEntry { + final String protocol; + final String localAddress; + final int localPort; + final String? process; + const PortEntry({ + required this.protocol, + required this.localAddress, + required this.localPort, + this.process, + }); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +```bash +cd app && flutter test test/models/system_snapshot_test.dart +``` + +Expected: All tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add app/lib/models/system_snapshot.dart app/test/models/system_snapshot_test.dart +git commit -m "feat(monitor): SystemSnapshot model with proc/stat+meminfo+df+ss parser" +``` + +--- + +## Task 2: FirewallStatus model + parser + +**Files:** +- Create: `app/lib/models/firewall_status.dart` +- Create: `app/test/models/firewall_status_test.dart` + +- [ ] **Step 1: Write the failing test** + +```dart +// app/test/models/firewall_status_test.dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:yourssh/models/firewall_status.dart'; + +void main() { + group('FirewallStatus.fromShellOutput', () { + test('parses active ufw with deny default and rules', () { + const output = ''' +Status: active +Logging: on (low) +Default: deny (incoming), allow (outgoing), disabled (routed) +New profiles: skip + + To Action From + -- ------ ---- +[ 1] 22/tcp ALLOW IN Anywhere +[ 2] 80/tcp ALLOW IN Anywhere +[ 3] 22/tcp (v6) ALLOW IN Anywhere (v6) +'''; + final s = FirewallStatus.fromShellOutput(output); + expect(s.type, FirewallType.ufw); + expect(s.enabled, isTrue); + expect(s.defaultInboundPolicy, 'DENY'); + expect(s.rules.length, 3); + expect(s.rules.first.action, 'ALLOW'); + }); + + test('parses inactive ufw', () { + final s = FirewallStatus.fromShellOutput('Status: inactive'); + expect(s.type, FirewallType.ufw); + expect(s.enabled, isFalse); + expect(s.rules, isEmpty); + }); + + test('parses iptables-save with DROP default', () { + const output = ''' +# Generated by iptables-save +*filter +:INPUT DROP [0:0] +:FORWARD DROP [0:0] +:OUTPUT ACCEPT [0:0] +-A INPUT -m state --state RELATED,ESTABLISHED -j ACCEPT +-A INPUT -p tcp --dport 22 -j ACCEPT +-A INPUT -p tcp --dport 80 -j ACCEPT +COMMIT +'''; + final s = FirewallStatus.fromShellOutput(output); + expect(s.type, FirewallType.iptables); + expect(s.enabled, isTrue); + expect(s.defaultInboundPolicy, 'DROP'); + final inputRules = s.rules.where((r) => r.chain == 'INPUT').toList(); + expect(inputRules.length, 3); + expect(inputRules.last.action, 'ACCEPT'); + }); + + test('parses nft ruleset', () { + const output = ''' +table inet filter { + chain input { + type filter hook input priority 0; policy drop; + ct state established,related accept + iif "lo" accept + tcp dport 22 accept + } +} +'''; + final s = FirewallStatus.fromShellOutput(output); + expect(s.type, FirewallType.nftables); + expect(s.enabled, isTrue); + expect(s.defaultInboundPolicy, 'DROP'); + expect(s.rules.isNotEmpty, isTrue); + }); + + test('returns none for __NO_FIREWALL__ sentinel', () { + final s = FirewallStatus.fromShellOutput('__NO_FIREWALL__\n'); + expect(s.type, FirewallType.none); + expect(s.enabled, isFalse); + expect(s.rules, isEmpty); + }); + + test('returns none for unrecognized output', () { + final s = FirewallStatus.fromShellOutput('some random text'); + expect(s.type, FirewallType.none); + }); + }); +} +``` + +- [ ] **Step 2: Run to confirm it fails** + +```bash +cd app && flutter test test/models/firewall_status_test.dart +``` + +Expected: compile error — file doesn't exist yet. + +- [ ] **Step 3: Implement `firewall_status.dart`** + +```dart +// app/lib/models/firewall_status.dart +enum FirewallType { ufw, iptables, nftables, none } + +class FirewallStatus { + final FirewallType type; + final bool enabled; + final String? defaultInboundPolicy; + final List rules; + + const FirewallStatus({ + required this.type, + required this.enabled, + this.defaultInboundPolicy, + required this.rules, + }); + + static const _kNone = FirewallStatus(type: FirewallType.none, enabled: false, rules: []); + + factory FirewallStatus.fromShellOutput(String output) { + if (output.contains('__NO_FIREWALL__')) return _kNone; + if (output.contains('Status: active') || output.contains('Status: inactive')) { + return _parseUfw(output); + } + if (output.contains('*filter') || + RegExp(r'-A (INPUT|OUTPUT|FORWARD)').hasMatch(output)) { + return _parseIptables(output); + } + if (output.contains('hook input') && output.contains('chain')) { + return _parseNft(output); + } + return _kNone; + } + + static FirewallStatus _parseUfw(String output) { + final enabled = output.contains('Status: active'); + String? defaultPolicy; + final rules = []; + for (final line in output.split('\n')) { + final t = line.trim(); + if (t.startsWith('Default:')) { + final m = RegExp(r'Default: (\w+) \(incoming\)').firstMatch(t); + defaultPolicy = m?.group(1)?.toUpperCase(); + } + final m = RegExp(r'^\[\s*\d+\]\s+(.+?)\s{2,}(ALLOW|DENY|LIMIT|REJECT)\s').firstMatch(t); + if (m != null) { + rules.add(FirewallRule(description: t, action: m.group(2), chain: null)); + } + } + return FirewallStatus( + type: FirewallType.ufw, enabled: enabled, + defaultInboundPolicy: defaultPolicy, rules: rules, + ); + } + + static FirewallStatus _parseIptables(String output) { + String? defaultPolicy; + final rules = []; + bool inFilter = false; + for (final line in output.split('\n')) { + final t = line.trim(); + if (t == '*filter') { inFilter = true; continue; } + if (t == 'COMMIT') { inFilter = false; continue; } + if (!inFilter) continue; + final chain = RegExp(r'^:INPUT (\w+)').firstMatch(t); + if (chain != null) defaultPolicy = chain.group(1); + final rule = RegExp(r'^-A (\w+) .+ -j (\w+)').firstMatch(t); + if (rule != null) { + rules.add(FirewallRule( + description: t, action: rule.group(2), chain: rule.group(1), + )); + } + } + return FirewallStatus( + type: FirewallType.iptables, enabled: true, + defaultInboundPolicy: defaultPolicy, rules: rules, + ); + } + + static FirewallStatus _parseNft(String output) { + final policyMatch = RegExp(r'hook input[^;]*;\s*policy (\w+);').firstMatch(output); + final defaultPolicy = policyMatch?.group(1)?.toUpperCase(); + final rules = []; + for (final line in output.split('\n')) { + final t = line.trim(); + if (t.isEmpty || t.startsWith('table') || t.startsWith('chain') || + t.startsWith('type') || t == '{' || t == '}') continue; + if (t.contains('accept') || t.contains('drop') || t.contains('reject')) { + final action = t.contains('accept') ? 'ACCEPT' + : t.contains('drop') ? 'DROP' : 'REJECT'; + rules.add(FirewallRule(description: t, action: action, chain: 'input')); + } + } + return FirewallStatus( + type: FirewallType.nftables, enabled: true, + defaultInboundPolicy: defaultPolicy, rules: rules, + ); + } +} + +class FirewallRule { + final String description; + final String? action; + final String? chain; + const FirewallRule({required this.description, this.action, this.chain}); +} +``` + +- [ ] **Step 4: Run tests** + +```bash +cd app && flutter test test/models/firewall_status_test.dart +``` + +Expected: All pass. + +- [ ] **Step 5: Commit** + +```bash +git add app/lib/models/firewall_status.dart app/test/models/firewall_status_test.dart +git commit -m "feat(monitor): FirewallStatus model with ufw/iptables/nftables parser" +``` + +--- + +## Task 3: SystemStatsService + test + +**Files:** +- Create: `app/lib/services/system_stats_service.dart` +- Create: `app/test/services/system_stats_service_test.dart` + +- [ ] **Step 1: Write the failing test** + +```dart +// app/test/services/system_stats_service_test.dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:yourssh/models/host.dart'; +import 'package:yourssh/models/system_snapshot.dart'; +import 'package:yourssh/services/system_stats_service.dart'; +import 'package:yourssh/services/ssh_service.dart'; + +class _FakeSsh extends Fake implements SshService { + final String stdout; + int callCount = 0; + _FakeSsh(this.stdout); + + @override + Future<({String stdout, String stderr, int exitCode})> exec( + Host host, + String command, { + String? auditSource = 'app', + }) async { + callCount++; + return (stdout: stdout, stderr: '', exitCode: 0); + } +} + +class _ThrowingSsh extends Fake implements SshService { + @override + Future<({String stdout, String stderr, int exitCode})> exec( + Host host, + String command, { + String? auditSource = 'app', + }) async { + throw Exception('disconnected'); + } +} + +Host _host() => Host( + id: 'h1', label: 'test', host: 'example.com', port: 22, username: 'root', +); + +const _kOutput = ''' +__CPU1__ +cpu 100 0 0 900 0 0 0 0 0 0 +__CPU2__ +cpu 110 0 0 910 0 0 0 0 0 0 +__MEM__ +MemTotal: 2048000 kB +MemAvailable: 1024000 kB +__DISK__ +Filesystem 1K-blocks Used Available Use% Mounted on +/dev/sda1 100000 50000 50000 50% / +__UPTIME__ +3600.0 1800.0 +__PORTS__ +'''; + +void main() { + group('SystemStatsService', () { + test('poll delivers parsed snapshot via onUpdate', () async { + SystemSnapshot? got; + final svc = SystemStatsService( + host: _host(), + sshService: _FakeSsh(_kOutput), + onUpdate: (s) => got = s, + ); + await svc.poll(); + expect(got, isNotNull); + expect(got!.disks.length, 1); + expect(got!.uptime, const Duration(hours: 1)); + expect(got!.totalMemBytes, 2048000 * 1024); + }); + + test('poll silently ignores exec exceptions', () async { + final svc = SystemStatsService( + host: _host(), + sshService: _ThrowingSsh(), + onUpdate: (_) => fail('should not call onUpdate'), + ); + await expectLater(svc.poll(), completes); // no throw + }); + + test('poll is not called before start()', () async { + final ssh = _FakeSsh(_kOutput); + SystemStatsService( + host: _host(), sshService: ssh, onUpdate: (_) {}, + ); + await Future.delayed(const Duration(milliseconds: 20)); + expect(ssh.callCount, 0); + }); + + test('stop() cancels the timer', () async { + final ssh = _FakeSsh(_kOutput); + final svc = SystemStatsService( + host: _host(), + sshService: ssh, + onUpdate: (_) {}, + ); + svc.start(interval: const Duration(milliseconds: 10)); + await Future.delayed(const Duration(milliseconds: 35)); + svc.stop(); + final countAfterStop = ssh.callCount; + await Future.delayed(const Duration(milliseconds: 30)); + expect(ssh.callCount, countAfterStop); + }); + }); +} +``` + +- [ ] **Step 2: Run to confirm it fails** + +```bash +cd app && flutter test test/services/system_stats_service_test.dart +``` + +Expected: compile error. + +- [ ] **Step 3: Implement `system_stats_service.dart`** + +```dart +// app/lib/services/system_stats_service.dart +import 'dart:async'; +import '../models/host.dart'; +import '../models/system_snapshot.dart'; +import 'ssh_service.dart'; + +class SystemStatsService { + Timer? _timer; + final Host host; + final SshService sshService; + final void Function(SystemSnapshot) onUpdate; + + SystemStatsService({ + required this.host, + required this.sshService, + required this.onUpdate, + }); + + void start({Duration interval = const Duration(seconds: 5)}) { + _timer?.cancel(); + _timer = Timer.periodic(interval, (_) => poll()); + } + + void stop() { + _timer?.cancel(); + _timer = null; + } + + /// Exposed for tests — fires one poll cycle immediately. + Future poll() async { + try { + final result = await sshService.exec(host, _kCommand, auditSource: null); + if (result.stdout.isEmpty) return; + onUpdate(SystemSnapshot.fromShellOutput(result.stdout)); + } catch (_) {} + } +} + +// Dart raw-string concatenation — compiled to a single string constant. +const _kCommand = + r'c1=$(grep -m1 "^cpu " /proc/stat 2>/dev/null); sleep 0.2; ' + r'c2=$(grep -m1 "^cpu " /proc/stat 2>/dev/null); ' + r'printf "__CPU1__\n%s\n__CPU2__\n%s\n" "$c1" "$c2"; ' + r'printf "__MEM__\n"; cat /proc/meminfo 2>/dev/null; ' + r'printf "__DISK__\n"; df -k 2>/dev/null; ' + r'printf "__UPTIME__\n"; cat /proc/uptime 2>/dev/null; ' + r'printf "__PORTS__\n"; ss -tulpn 2>/dev/null || netstat -tulpn 2>/dev/null'; +``` + +- [ ] **Step 4: Run tests** + +```bash +cd app && flutter test test/services/system_stats_service_test.dart +``` + +Expected: All pass. + +- [ ] **Step 5: Commit** + +```bash +git add app/lib/services/system_stats_service.dart app/test/services/system_stats_service_test.dart +git commit -m "feat(monitor): SystemStatsService — 5s polling via SSH exec" +``` + +--- + +## Task 4: FirewallStatusService + test + +**Files:** +- Create: `app/lib/services/firewall_status_service.dart` +- Create: `app/test/services/firewall_status_service_test.dart` + +- [ ] **Step 1: Write the failing test** + +```dart +// app/test/services/firewall_status_service_test.dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:yourssh/models/firewall_status.dart'; +import 'package:yourssh/models/host.dart'; +import 'package:yourssh/services/firewall_status_service.dart'; +import 'package:yourssh/services/ssh_service.dart'; + +class _FakeSsh extends Fake implements SshService { + final String stdout; + _FakeSsh(this.stdout); + @override + Future<({String stdout, String stderr, int exitCode})> exec( + Host host, String command, {String? auditSource = 'app'}) async => + (stdout: stdout, stderr: '', exitCode: 0); +} + +class _ThrowingSsh extends Fake implements SshService { + @override + Future<({String stdout, String stderr, int exitCode})> exec( + Host host, String command, {String? auditSource = 'app'}) async => + throw Exception('err'); +} + +Host _host() => Host( + id: 'h1', label: 'test', host: 'example.com', port: 22, username: 'root', +); + +void main() { + group('FirewallStatusService', () { + test('poll delivers parsed FirewallStatus via onUpdate', () async { + FirewallStatus? got; + final svc = FirewallStatusService( + host: _host(), + sshService: _FakeSsh('Status: active\nDefault: deny (incoming), allow (outgoing)\n'), + onUpdate: (f) => got = f, + ); + await svc.poll(); + expect(got, isNotNull); + expect(got!.type, FirewallType.ufw); + expect(got!.enabled, isTrue); + }); + + test('poll silently ignores exec exceptions', () async { + final svc = FirewallStatusService( + host: _host(), + sshService: _ThrowingSsh(), + onUpdate: (_) => fail('should not call'), + ); + await expectLater(svc.poll(), completes); + }); + + test('poll delivers none type for __NO_FIREWALL__', () async { + FirewallStatus? got; + final svc = FirewallStatusService( + host: _host(), + sshService: _FakeSsh('__NO_FIREWALL__'), + onUpdate: (f) => got = f, + ); + await svc.poll(); + expect(got!.type, FirewallType.none); + }); + }); +} +``` + +- [ ] **Step 2: Run to confirm it fails** + +```bash +cd app && flutter test test/services/firewall_status_service_test.dart +``` + +- [ ] **Step 3: Implement `firewall_status_service.dart`** + +```dart +// app/lib/services/firewall_status_service.dart +import 'dart:async'; +import '../models/firewall_status.dart'; +import '../models/host.dart'; +import 'ssh_service.dart'; + +class FirewallStatusService { + Timer? _timer; + final Host host; + final SshService sshService; + final void Function(FirewallStatus) onUpdate; + + FirewallStatusService({ + required this.host, + required this.sshService, + required this.onUpdate, + }); + + void start({Duration interval = const Duration(seconds: 30)}) { + _timer?.cancel(); + _timer = Timer.periodic(interval, (_) => poll()); + } + + void stop() { + _timer?.cancel(); + _timer = null; + } + + /// Exposed for tests. + Future poll() async { + try { + final result = await sshService.exec(host, _kCommand, auditSource: null); + onUpdate(FirewallStatus.fromShellOutput(result.stdout)); + } catch (_) {} + } +} + +const _kCommand = + 'ufw status numbered 2>/dev/null || ' + 'iptables-save 2>/dev/null || ' + 'nft list ruleset 2>/dev/null || ' + 'echo __NO_FIREWALL__'; +``` + +- [ ] **Step 4: Run tests** + +```bash +cd app && flutter test test/services/firewall_status_service_test.dart +``` + +Expected: All pass. + +- [ ] **Step 5: Commit** + +```bash +git add app/lib/services/firewall_status_service.dart app/test/services/firewall_status_service_test.dart +git commit -m "feat(monitor): FirewallStatusService — 30s polling for ufw/iptables/nftables" +``` + +--- + +## Task 5: ServerMonitorSheet widget + +**Files:** +- Create: `app/lib/widgets/server_monitor_sheet.dart` +- Create: `app/test/widgets/server_monitor_sheet_test.dart` + +- [ ] **Step 1: Write the failing test** + +```dart +// app/test/widgets/server_monitor_sheet_test.dart +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:yourssh/models/firewall_status.dart'; +import 'package:yourssh/models/host.dart'; +import 'package:yourssh/models/system_snapshot.dart'; +import 'package:yourssh/providers/session_provider.dart'; +import 'package:yourssh/services/ssh_service.dart'; +import 'package:yourssh/widgets/server_monitor_sheet.dart'; + +// sshSessions is List — use testIsConnected to bypass the +// provider check without needing a real SshSession in tests. +class _FakeSsh extends Fake implements SshService {} + +Widget _wrap(Widget child) => MultiProvider( + providers: [ + Provider(create: (_) => _FakeSsh()), + ], + child: MaterialApp(home: Scaffold(body: child)), + ); + +Host _host() => Host( + id: 'h1', label: 'ubuntu-prod', host: 'example.com', + port: 22, username: 'root', + ); + +void main() { + group('ServerMonitorSheet', () { + testWidgets('shows not-connected message when testIsConnected is false', + (tester) async { + await tester.pumpWidget( + _wrap(ServerMonitorSheet(host: _host(), testIsConnected: false)), + ); + expect(find.textContaining('No active session'), findsOneWidget); + }); + + testWidgets('shows loading indicators while awaiting first snapshot', + (tester) async { + await tester.pumpWidget( + _wrap(ServerMonitorSheet(host: _host(), testIsConnected: true)), + ); + expect(find.byType(CircularProgressIndicator), findsWidgets); + }); + + testWidgets('renders cpu/memory section after debugSetSnapshot', + (tester) async { + await tester.pumpWidget( + _wrap(ServerMonitorSheet(host: _host(), testIsConnected: true)), + ); + final state = tester.state( + find.byType(ServerMonitorSheet), + ); + state.debugSetSnapshot(SystemSnapshot( + cpuPercent: 42.5, + totalMemBytes: 8 * 1024 * 1024 * 1024, + usedMemBytes: 3 * 1024 * 1024 * 1024, + disks: [DiskMount(source: '/dev/sda1', mountPoint: '/', totalKb: 100000, usedKb: 45000)], + uptime: const Duration(hours: 14, minutes: 3), + ports: [PortEntry(protocol: 'tcp', localAddress: '0.0.0.0', localPort: 22, process: 'sshd')], + timestamp: DateTime.now(), + )); + await tester.pump(); + expect(find.textContaining('42'), findsOneWidget); // cpu % + expect(find.textContaining('sshd'), findsOneWidget); + }); + + testWidgets('renders firewall section after debugSetFirewall', + (tester) async { + await tester.pumpWidget( + _wrap(ServerMonitorSheet(host: _host(), testIsConnected: true)), + ); + final state = tester.state( + find.byType(ServerMonitorSheet), + ); + state.debugSetFirewall(const FirewallStatus( + type: FirewallType.ufw, enabled: true, + defaultInboundPolicy: 'DENY', + rules: [FirewallRule(description: '22/tcp ALLOW anywhere', action: 'ALLOW')], + )); + await tester.pump(); + expect(find.textContaining('ufw'), findsOneWidget); + expect(find.textContaining('DENY'), findsOneWidget); + }); + }); +} +``` + +- [ ] **Step 2: Run to confirm it fails** + +```bash +cd app && flutter test test/widgets/server_monitor_sheet_test.dart +``` + +- [ ] **Step 3: Implement `server_monitor_sheet.dart`** + +```dart +// app/lib/widgets/server_monitor_sheet.dart +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../models/firewall_status.dart'; +import '../models/host.dart'; +import '../models/system_snapshot.dart'; +import '../providers/session_provider.dart'; +import '../services/firewall_status_service.dart'; +import '../services/ssh_service.dart'; +import '../services/system_stats_service.dart'; +import '../theme/app_theme.dart'; + +class ServerMonitorSheet extends StatefulWidget { + final Host host; + // Bypasses the SessionProvider check in tests — null means use the real check. + @visibleForTesting + final bool? testIsConnected; + + const ServerMonitorSheet({super.key, required this.host, this.testIsConnected}); + + static void show(BuildContext context, Host host) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (_) => ServerMonitorSheet(host: host), + ); + } + + @override + State createState() => ServerMonitorSheetState(); +} + +// Public so tests can cast to it. +class ServerMonitorSheetState extends State { + SystemStatsService? _statsService; + FirewallStatusService? _firewallService; + SystemSnapshot? _snapshot; + FirewallStatus? _firewall; + bool _started = false; + + @visibleForTesting + void debugSetSnapshot(SystemSnapshot s) => setState(() => _snapshot = s); + + @visibleForTesting + void debugSetFirewall(FirewallStatus f) => setState(() => _firewall = f); + + bool _isConnected(BuildContext context) => + widget.testIsConnected ?? + context + .read() + .sshSessions + .any((s) => s.host.id == widget.host.id); + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (_started) return; + _started = true; + if (!_isConnected(context)) return; + final ssh = context.read(); + _statsService = SystemStatsService( + host: widget.host, sshService: ssh, + onUpdate: (s) { if (mounted) setState(() => _snapshot = s); }, + ); + _firewallService = FirewallStatusService( + host: widget.host, sshService: ssh, + onUpdate: (f) { if (mounted) setState(() => _firewall = f); }, + ); + _statsService!.start(); + _firewallService!.start(); + // Deliver first reading immediately instead of waiting for the first tick. + _statsService!.poll(); + _firewallService!.poll(); + } + + @override + void dispose() { + _statsService?.stop(); + _firewallService?.stop(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final isConnected = _isConnected(context); + return DraggableScrollableSheet( + initialChildSize: 0.6, + minChildSize: 0.4, + maxChildSize: 0.95, + builder: (_, ctrl) => Container( + decoration: const BoxDecoration( + color: Color(0xFF1A1A1A), + borderRadius: BorderRadius.vertical(top: Radius.circular(12)), + border: Border(top: BorderSide(color: Color(0xFF2A2A2A))), + ), + child: Column(children: [ + _handle(), + _header(), + Expanded( + child: isConnected + ? _body(ctrl) + : _notConnected(), + ), + ]), + ), + ); + } + + Widget _handle() => Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Container( + width: 36, height: 4, + decoration: BoxDecoration( + color: const Color(0xFF3A3A3A), + borderRadius: BorderRadius.circular(2), + ), + ), + ); + + Widget _header() => Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), + child: Row(children: [ + Text(widget.host.label, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 15, fontWeight: FontWeight.w600)), + const Spacer(), + if (_snapshot != null) + Row(children: [ + Container( + width: 7, height: 7, + decoration: const BoxDecoration( + color: AppColors.accent, shape: BoxShape.circle)), + const SizedBox(width: 5), + const Text('Live', + style: TextStyle(color: AppColors.accent, fontSize: 11)), + ]), + ]), + ); + + Widget _notConnected() => const Center( + child: Text('No active session — open a terminal first', + style: TextStyle(color: AppColors.textSecondary)), + ); + + Widget _body(ScrollController ctrl) => ListView( + controller: ctrl, + padding: const EdgeInsets.fromLTRB(16, 0, 16, 24), + children: [ + _sectionTitle('SYSTEM'), + _snapshot == null + ? const Center(child: CircularProgressIndicator()) + : _systemSection(_snapshot!), + const SizedBox(height: 16), + _sectionTitle('PORTS'), + _snapshot == null + ? const Center(child: CircularProgressIndicator()) + : _portsSection(_snapshot!.ports), + const SizedBox(height: 16), + _sectionTitle('FIREWALL'), + _firewall == null + ? const Center(child: CircularProgressIndicator()) + : _firewallSection(_firewall!), + ], + ); + + Widget _sectionTitle(String t) => Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text(t, + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 11, letterSpacing: 0.8)), + ); + + Widget _systemSection(SystemSnapshot s) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _statRow('Uptime', SystemSnapshot.formatUptime(s.uptime)), + const SizedBox(height: 6), + _barRow('CPU', s.cpuPercent / 100, + '${s.cpuPercent.toStringAsFixed(1)}%'), + const SizedBox(height: 6), + _barRow( + 'Memory', + s.totalMemBytes == 0 ? 0 : s.usedMemBytes / s.totalMemBytes, + '${SystemSnapshot.formatBytes(s.usedMemBytes)} / ${SystemSnapshot.formatBytes(s.totalMemBytes)}', + ), + ...s.disks.map((d) => Padding( + padding: const EdgeInsets.only(top: 6), + child: _barRow( + d.mountPoint, + d.usedPercent, + '${d.usedPercent * 100 ~/ 1}% of ${SystemSnapshot.formatBytes(d.totalKb * 1024)}', + ), + )), + ], + ); + + Widget _statRow(String label, String value) => Row(children: [ + SizedBox( + width: 72, + child: Text(label, + style: const TextStyle(color: AppColors.textSecondary, fontSize: 12))), + Text(value, + style: const TextStyle(color: AppColors.textPrimary, fontSize: 12)), + ]); + + Widget _barRow(String label, double fraction, String right) => Row(children: [ + SizedBox( + width: 72, + child: Text(label, + style: const TextStyle( + color: AppColors.textSecondary, fontSize: 12), + overflow: TextOverflow.ellipsis)), + Expanded( + child: ClipRRect( + borderRadius: BorderRadius.circular(2), + child: LinearProgressIndicator( + value: fraction.clamp(0.0, 1.0), + minHeight: 6, + backgroundColor: const Color(0xFF2A2A2A), + color: fraction > 0.85 ? AppColors.red : AppColors.accent, + ), + ), + ), + const SizedBox(width: 8), + Text(right, + style: + const TextStyle(color: AppColors.textSecondary, fontSize: 11)), + ]); + + Widget _portsSection(List ports) { + if (ports.isEmpty) { + return const Text('No listening ports detected', + style: TextStyle(color: AppColors.textSecondary, fontSize: 12)); + } + return Column( + children: ports + .map((p) => Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row(children: [ + SizedBox( + width: 32, + child: Text(p.protocol, + style: const TextStyle( + color: AppColors.textSecondary, fontSize: 11, + fontFamily: 'monospace'))), + SizedBox( + width: 80, + child: Text(':${p.localPort}', + style: const TextStyle( + color: AppColors.textPrimary, fontSize: 12, + fontFamily: 'monospace'))), + Expanded( + child: Text(p.process ?? '—', + style: const TextStyle( + color: AppColors.textSecondary, fontSize: 11), + overflow: TextOverflow.ellipsis)), + ]), + )) + .toList(), + ); + } + + Widget _firewallSection(FirewallStatus fw) { + if (fw.type == FirewallType.none) { + return const Text('No firewall detected', + style: TextStyle(color: AppColors.textSecondary, fontSize: 12)); + } + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row(children: [ + _chip(fw.type.name, + fw.enabled ? AppColors.accent : AppColors.textSecondary), + const SizedBox(width: 8), + _chip(fw.enabled ? 'active' : 'inactive', + fw.enabled ? AppColors.accent : AppColors.red), + if (fw.defaultInboundPolicy != null) ...[ + const SizedBox(width: 8), + Text('default inbound: ${fw.defaultInboundPolicy}', + style: const TextStyle( + color: AppColors.textSecondary, fontSize: 11)), + ], + ]), + if (fw.rules.isNotEmpty) ...[ + const SizedBox(height: 8), + ...fw.rules.map((r) => Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Text(r.description, + style: const TextStyle( + color: AppColors.textPrimary, fontSize: 11, + fontFamily: 'monospace'), + overflow: TextOverflow.ellipsis), + )), + ], + ], + ); + } + + Widget _chip(String label, Color color) => Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(4), + border: Border.all(color: color.withValues(alpha: 0.3)), + ), + child: Text(label, + style: TextStyle(color: color, fontSize: 11)), + ); +} +``` + +- [ ] **Step 4: Run tests** + +```bash +cd app && flutter test test/widgets/server_monitor_sheet_test.dart +``` + +Expected: All pass. + +- [ ] **Step 5: Commit** + +```bash +git add app/lib/widgets/server_monitor_sheet.dart app/test/widgets/server_monitor_sheet_test.dart +git commit -m "feat(monitor): ServerMonitorSheet — draggable bottom sheet with CPU/mem/disk/ports/firewall" +``` + +--- + +## Task 6: Wire into HostsDashboard + +**Files:** +- Modify: `app/lib/widgets/hosts_dashboard.dart` + +- [ ] **Step 1: Add the monitor button to `_trailing()` (inside `_HostCardState`)** + +In `hosts_dashboard.dart`, find the `_trailing()` method (~line 1047). After the SFTP button and before the "More" button, insert the monitor button — visible only when the host has an active SSH session and it's an SSH host: + +```dart +// In _trailing(), inside the `if (!widget.selectionMode && _hovered && !_testing && _testResult == null)` block, +// add after the SFTP _iconBtn and before the 'More' _iconBtn: +if (isSsh && context.read().sshSessions.containsKey(widget.host.id)) ...[ + _iconBtn(Icons.monitor_heart_outlined, 'Monitor', + onTap: () => ServerMonitorSheet.show(context, widget.host)), + const SizedBox(width: 2), +], +``` + +The full updated block in `_trailing()`: +```dart +if (!widget.selectionMode && _hovered && !_testing && _testResult == null) ...[ + if (isSsh) ...[ + _iconBtn(Icons.network_check, 'Test Connection', onTap: _test), + const SizedBox(width: 2), + _iconBtn(Icons.folder_outlined, 'SFTP', onTap: () => _openSftp(context)), + const SizedBox(width: 2), + if (context.read().sshSessions.any((s) => s.host.id == widget.host.id)) ...[ + _iconBtn(Icons.monitor_heart_outlined, 'Monitor', + onTap: () => ServerMonitorSheet.show(context, widget.host)), + const SizedBox(width: 2), + ], + ], + _iconBtn(Icons.more_horiz, 'More', + onTapDown: (d) => _showMenu(context, d.globalPosition)), +], +``` + +- [ ] **Step 2: Add "Monitor" item to the context menu (`_showMenu()`)** + +In `_showMenu()`, after the `if (isSsh) _menuItem('sftp', ...)` line, add: + +```dart +_menuItem('monitor', Icons.monitor_heart_outlined, 'Monitor', + () => ServerMonitorSheet.show(context, widget.host)), +``` + +Full updated items list in `_showMenu()`: +```dart +items: >[ + _menuItem('terminal', Icons.terminal, 'Connect', + () => sessionProvider.connectAny(widget.host)), + if (isSsh) + _menuItem('sftp', Icons.folder_outlined, 'SFTP', + () => _openSftp(context)), + if (isSsh) + _menuItem('monitor', Icons.monitor_heart_outlined, 'Monitor', + () => ServerMonitorSheet.show(context, widget.host)), + _menuItem('edit', Icons.edit_outlined, 'Edit', + () => widget.onEditHost?.call(widget.host)), + const PopupMenuDivider(), + _menuItem('duplicate', Icons.copy_outlined, 'Duplicate', + () => _duplicate(context, hostProvider)), + _menuItem('copy_url', Icons.link_outlined, + isSsh ? 'Copy SSH URL' : 'Copy RDP URL', + () => _copyHostUrl(context)), + _menuItem('move_group', Icons.drive_file_move_outlined, 'Move to Group', + () => _moveToGroup(context, hostProvider)), + _menuItem('export', Icons.upload_outlined, 'Export', + () => _export(context)), + const PopupMenuDivider(), + _menuItem('delete', Icons.delete_outlined, 'Delete', + () => hostProvider.deleteHost(widget.host.id), color: AppColors.red), +], +``` + +- [ ] **Step 3: Add the import at the top of `hosts_dashboard.dart`** + +```dart +import 'server_monitor_sheet.dart'; +``` + +- [ ] **Step 4: Analyze and run all tests** + +```bash +cd app && flutter analyze && flutter test +``` + +Expected: No analysis errors, all tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add app/lib/widgets/hosts_dashboard.dart +git commit -m "feat(monitor): wire ServerMonitorSheet into host card hover button + context menu" +``` + +--- + +## Task 7: Run all tests + final check + +- [ ] **Step 1: Run full test suite** + +```bash +cd app && flutter test +``` + +Expected: All tests pass, no regressions. + +- [ ] **Step 2: Run analyzer** + +```bash +cd app && flutter analyze +``` + +Expected: No issues. + +- [ ] **Step 3: Verify the feature compiles for macOS** + +```bash +cd app && flutter build macos --debug 2>&1 | tail -5 +``` + +Expected: `Build complete.` (or similar, no errors). + +- [ ] **Step 4: Final commit (if any lint fixes were needed)** + +```bash +git add -p +git commit -m "chore(monitor): lint fixes from analyzer" +``` + +Only needed if Step 2 found issues. From 48102bf2484fed2cd676de25d3dfd1883234191b Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Mon, 8 Jun 2026 08:21:53 +0700 Subject: [PATCH 15/59] feat(monitor): SystemSnapshot model with proc/stat+meminfo+df+ss parser --- app/lib/models/system_snapshot.dart | 219 ++++++++++++++++++++++ app/test/models/system_snapshot_test.dart | 119 ++++++++++++ 2 files changed, 338 insertions(+) create mode 100644 app/lib/models/system_snapshot.dart create mode 100644 app/test/models/system_snapshot_test.dart diff --git a/app/lib/models/system_snapshot.dart b/app/lib/models/system_snapshot.dart new file mode 100644 index 00000000..1ff1f061 --- /dev/null +++ b/app/lib/models/system_snapshot.dart @@ -0,0 +1,219 @@ +class SystemSnapshot { + final double cpuPercent; + final int totalMemBytes; + final int usedMemBytes; + final List disks; + final Duration uptime; + final List ports; + final DateTime timestamp; + + const SystemSnapshot({ + required this.cpuPercent, + required this.totalMemBytes, + required this.usedMemBytes, + required this.disks, + required this.uptime, + required this.ports, + required this.timestamp, + }); + + factory SystemSnapshot.fromShellOutput(String output) { + final sections = _splitSections(output); + final mem = _parseMem(sections['__MEM__'] ?? ''); + return SystemSnapshot( + cpuPercent: _parseCpuPercent( + sections['__CPU1__'] ?? '', + sections['__CPU2__'] ?? '', + ), + totalMemBytes: mem.$1, + usedMemBytes: mem.$2, + disks: _parseDisks(sections['__DISK__'] ?? ''), + uptime: _parseUptime(sections['__UPTIME__'] ?? ''), + ports: _parsePorts(sections['__PORTS__'] ?? ''), + timestamp: DateTime.now(), + ); + } + + static Map _splitSections(String output) { + const sentinels = { + '__CPU1__', '__CPU2__', '__MEM__', '__DISK__', '__UPTIME__', '__PORTS__', + }; + final sections = {}; + String? currentKey; + final buf = StringBuffer(); + for (final line in output.split('\n')) { + final t = line.trim(); + if (sentinels.contains(t)) { + if (currentKey != null) sections[currentKey] = buf.toString(); + currentKey = t; + buf.clear(); + } else if (currentKey != null) { + buf.writeln(line); + } + } + if (currentKey != null) sections[currentKey] = buf.toString(); + return sections; + } + + static double _parseCpuPercent(String cpu1, String cpu2) { + final s1 = _cpuStats(cpu1.trim()); + final s2 = _cpuStats(cpu2.trim()); + if (s1 == null || s2 == null || s1.length < 5 || s2.length < 5) return 0.0; + final total1 = s1.reduce((a, b) => a + b); + final idle1 = s1[3] + s1[4]; // idle + iowait + final total2 = s2.reduce((a, b) => a + b); + final idle2 = s2[3] + s2[4]; + final dTotal = total2 - total1; + final dIdle = idle2 - idle1; + if (dTotal <= 0) return 0.0; + return ((1.0 - dIdle / dTotal) * 100.0).clamp(0.0, 100.0); + } + + static List? _cpuStats(String line) { + final parts = line.split(RegExp(r'\s+')); + if (parts.isEmpty || !parts[0].startsWith('cpu')) return null; + return parts.skip(1).map((s) => int.tryParse(s) ?? 0).toList(); + } + + static (int, int) _parseMem(String section) { + int totalKb = 0, availableKb = 0; + for (final line in section.split('\n')) { + final parts = line.trim().split(RegExp(r'\s+')); + if (parts.length < 2) continue; + final val = int.tryParse(parts[1]) ?? 0; + if (parts[0] == 'MemTotal:') totalKb = val; + if (parts[0] == 'MemAvailable:') availableKb = val; + } + final total = totalKb * 1024; + final used = ((totalKb - availableKb) * 1024).clamp(0, total); + return (total, used); + } + + static const _kSkipFs = { + 'tmpfs', 'devtmpfs', 'overlay', 'squashfs', 'udev', 'run', 'none', + }; + + static List _parseDisks(String section) { + final result = []; + final lines = section.split('\n').skip(1); // skip header + for (final line in lines) { + final parts = line.trim().split(RegExp(r'\s+')); + if (parts.length < 6) continue; + final source = parts[0]; + if (_kSkipFs.any((f) => source.startsWith(f))) continue; + final totalKb = int.tryParse(parts[1]) ?? 0; + final usedKb = int.tryParse(parts[2]) ?? 0; + final mount = parts[5]; + result.add(DiskMount(source: source, mountPoint: mount, totalKb: totalKb, usedKb: usedKb)); + } + return result; + } + + static Duration _parseUptime(String section) { + final line = section.trim().split('\n').first.trim(); + final secs = double.tryParse(line.split(' ').first) ?? 0.0; + return Duration(seconds: secs.floor()); + } + + static List _parsePorts(String section) { + final entries = []; + for (final line in section.split('\n')) { + final parts = line.trim().split(RegExp(r'\s+')); + if (parts.length < 5) continue; + final proto = parts[0].toLowerCase(); + if (!proto.startsWith('tcp') && !proto.startsWith('udp')) continue; + + String? localAddr; + String? processStr; + + // ss format: State is parts[1] (LISTEN/UNCONN), local addr is parts[4] + if (parts[1] == 'LISTEN' || parts[1] == 'UNCONN') { + localAddr = parts[4]; + processStr = parts.length > 6 ? parts.skip(6).join(' ') : null; + } + // netstat format: State is parts[5], local addr is parts[3] + else if (parts.length >= 6 && parts[5] == 'LISTEN') { + localAddr = parts[3]; + processStr = parts.length > 6 ? parts[6] : null; + } + + if (localAddr == null) continue; + final lastColon = localAddr.lastIndexOf(':'); + if (lastColon < 0) continue; + final port = int.tryParse(localAddr.substring(lastColon + 1)); + if (port == null) continue; + + entries.add(PortEntry( + protocol: proto.replaceAll('6', '').replaceAll('4', ''), + localAddress: localAddr.substring(0, lastColon), + localPort: port, + process: _extractProcess(processStr), + )); + } + + // Deduplicate by port number, sort ascending + final seen = {}; + return entries.where((e) => seen.add(e.localPort)).toList() + ..sort((a, b) => a.localPort.compareTo(b.localPort)); + } + + static String? _extractProcess(String? raw) { + if (raw == null) return null; + // ss: users:(("sshd",pid=1234,fd=3)) + final ssMatch = RegExp(r'"([^"]+)"').firstMatch(raw); + if (ssMatch != null) return ssMatch.group(1); + // netstat: 1234/sshd + final parts = raw.split('/'); + return parts.length > 1 ? parts.last.trim() : null; + } + + static String formatBytes(int bytes) { + if (bytes < 1024) return '$bytes B'; + if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; + if (bytes < 1024 * 1024 * 1024) { + return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; + } + return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB'; + } + + static String formatUptime(Duration d) { + final days = d.inDays; + final hours = d.inHours % 24; + final minutes = d.inMinutes % 60; + final parts = []; + if (days > 0) parts.add('${days}d'); + if (hours > 0) parts.add('${hours}h'); + parts.add('${minutes}m'); + return parts.join(' '); + } +} + +class DiskMount { + final String source; + final String mountPoint; + final int totalKb; + final int usedKb; + + const DiskMount({ + required this.source, + required this.mountPoint, + required this.totalKb, + required this.usedKb, + }); + + double get usedPercent => totalKb == 0 ? 0.0 : usedKb / totalKb; +} + +class PortEntry { + final String protocol; + final String localAddress; + final int localPort; + final String? process; + + const PortEntry({ + required this.protocol, + required this.localAddress, + required this.localPort, + this.process, + }); +} diff --git a/app/test/models/system_snapshot_test.dart b/app/test/models/system_snapshot_test.dart new file mode 100644 index 00000000..a9b543e0 --- /dev/null +++ b/app/test/models/system_snapshot_test.dart @@ -0,0 +1,119 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:yourssh/models/system_snapshot.dart'; + +const _kFullOutput = ''' +__CPU1__ +cpu 454542 3253 96425 15678532 5432 0 1234 0 0 0 +__CPU2__ +cpu 454643 3253 96431 15679102 5435 0 1235 0 0 0 +__MEM__ +MemTotal: 16384000 kB +MemFree: 8192000 kB +MemAvailable: 9216000 kB +Buffers: 512000 kB +Cached: 1024000 kB +__DISK__ +Filesystem 1K-blocks Used Available Use% Mounted on +/dev/sda1 120000000 54000000 66000000 45% / +/dev/sdb1 20000000 4000000 16000000 20% /data +tmpfs 8192000 0 8192000 0% /dev/shm +devtmpfs 4096000 0 4096000 0% /dev +__UPTIME__ +1234567.89 432.10 +__PORTS__ +Netid State Recv-Q Send-Q Local Address:Port Peer Address:Port Process +tcp LISTEN 0 128 0.0.0.0:22 0.0.0.0:* users:(("sshd",pid=1234,fd=3)) +tcp LISTEN 0 128 0.0.0.0:80 0.0.0.0:* users:(("nginx",pid=5678,fd=6)) +tcp6 LISTEN 0 128 [::]:22 [::]:* users:(("sshd",pid=1234,fd=3)) +udp UNCONN 0 0 127.0.0.53%lo:53 0.0.0.0:* users:(("systemd-resolve",pid=567,fd=12)) +'''; + +void main() { + group('SystemSnapshot.fromShellOutput', () { + test('parses cpu percent from two proc/stat reads', () { + final s = SystemSnapshot.fromShellOutput(_kFullOutput); + // totalDelta=681, idleDelta=573 → (1 - 573/681)*100 ≈ 15.86% + expect(s.cpuPercent, closeTo(15.86, 0.5)); + }); + + test('parses memory', () { + final s = SystemSnapshot.fromShellOutput(_kFullOutput); + expect(s.totalMemBytes, 16384000 * 1024); + expect(s.usedMemBytes, (16384000 - 9216000) * 1024); + }); + + test('parses disks and skips tmpfs/devtmpfs', () { + final s = SystemSnapshot.fromShellOutput(_kFullOutput); + expect(s.disks.length, 2); + expect(s.disks.map((d) => d.mountPoint), containsAll(['/', '/data'])); + expect(s.disks.any((d) => d.source.startsWith('tmpfs')), isFalse); + }); + + test('parses uptime', () { + final s = SystemSnapshot.fromShellOutput(_kFullOutput); + expect(s.uptime, const Duration(seconds: 1234567)); + }); + + test('parses ports and deduplicates tcp/tcp6', () { + final s = SystemSnapshot.fromShellOutput(_kFullOutput); + // port 22 appears as tcp + tcp6 → deduplicated to 1 + expect(s.ports.length, 3); // 22 (deduped), 80, 53 + expect(s.ports.map((p) => p.localPort), containsAll([22, 80, 53])); + final ssh = s.ports.firstWhere((p) => p.localPort == 22); + expect(ssh.protocol, 'tcp'); + expect(ssh.process, 'sshd'); + }); + + test('returns zeroes on empty output', () { + final s = SystemSnapshot.fromShellOutput(''); + expect(s.cpuPercent, 0.0); + expect(s.totalMemBytes, 0); + expect(s.disks, isEmpty); + expect(s.ports, isEmpty); + }); + + test('parses netstat -tulpn port format', () { + const output = ''' +__CPU1__ +cpu 100 0 0 900 0 0 0 0 0 0 +__CPU2__ +cpu 101 0 0 901 0 0 0 0 0 0 +__MEM__ +MemTotal: 1024 kB +MemAvailable: 512 kB +__DISK__ +Filesystem 1K-blocks Used Available Use% Mounted on +/dev/sda1 100000 50000 50000 50% / +__UPTIME__ +100.0 50.0 +__PORTS__ +Proto Recv-Q Send-Q Local Address Foreign Address State PID/Program name +tcp 0 0 0.0.0.0:22 0.0.0.0:* LISTEN 1234/sshd +tcp6 0 0 :::22 :::* LISTEN 1234/sshd +'''; + final s = SystemSnapshot.fromShellOutput(output); + expect(s.ports.length, 1); + expect(s.ports.first.localPort, 22); + }); + + test('DiskMount.usedPercent is clamped 0..1', () { + final d = DiskMount(source: '/dev/sda1', mountPoint: '/', totalKb: 100, usedKb: 55); + expect(d.usedPercent, closeTo(0.55, 0.01)); + final empty = DiskMount(source: '/dev/sda1', mountPoint: '/', totalKb: 0, usedKb: 0); + expect(empty.usedPercent, 0.0); + }); + + test('formatUptime renders days/hours/minutes', () { + expect(SystemSnapshot.formatUptime(const Duration(days: 14, hours: 3, minutes: 22)), '14d 3h 22m'); + expect(SystemSnapshot.formatUptime(const Duration(minutes: 5)), '5m'); + expect(SystemSnapshot.formatUptime(const Duration(hours: 2)), '2h 0m'); + }); + + test('formatBytes scales correctly', () { + expect(SystemSnapshot.formatBytes(500), '500 B'); + expect(SystemSnapshot.formatBytes(2048), '2.0 KB'); + expect(SystemSnapshot.formatBytes(3 * 1024 * 1024), '3.0 MB'); + expect(SystemSnapshot.formatBytes(2 * 1024 * 1024 * 1024), '2.0 GB'); + }); + }); +} From 5c93a0348c793e13c73c4ad141b33ea673f66347 Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Mon, 8 Jun 2026 08:22:40 +0700 Subject: [PATCH 16/59] feat(monitor): FirewallStatus model with ufw/iptables/nftables parser --- app/lib/models/firewall_status.dart | 134 ++++++++++++++++++++++ app/test/models/firewall_status_test.dart | 85 ++++++++++++++ 2 files changed, 219 insertions(+) create mode 100644 app/lib/models/firewall_status.dart create mode 100644 app/test/models/firewall_status_test.dart diff --git a/app/lib/models/firewall_status.dart b/app/lib/models/firewall_status.dart new file mode 100644 index 00000000..433d74ce --- /dev/null +++ b/app/lib/models/firewall_status.dart @@ -0,0 +1,134 @@ +enum FirewallType { ufw, iptables, nftables, none } + +class FirewallStatus { + final FirewallType type; + final bool enabled; + final String? defaultInboundPolicy; + final List rules; + + const FirewallStatus({ + required this.type, + required this.enabled, + this.defaultInboundPolicy, + required this.rules, + }); + + static const _kNone = FirewallStatus( + type: FirewallType.none, + enabled: false, + rules: [], + ); + + factory FirewallStatus.fromShellOutput(String output) { + if (output.contains('__NO_FIREWALL__')) return _kNone; + if (output.contains('Status: active') || output.contains('Status: inactive')) { + return _parseUfw(output); + } + if (output.contains('*filter') || + RegExp(r'-A (INPUT|OUTPUT|FORWARD)').hasMatch(output)) { + return _parseIptables(output); + } + if (output.contains('hook input') && output.contains('chain')) { + return _parseNft(output); + } + return _kNone; + } + + static FirewallStatus _parseUfw(String output) { + final enabled = output.contains('Status: active'); + String? defaultPolicy; + final rules = []; + for (final line in output.split('\n')) { + final t = line.trim(); + if (t.startsWith('Default:')) { + final m = RegExp(r'Default: (\w+) \(incoming\)').firstMatch(t); + defaultPolicy = m?.group(1)?.toUpperCase(); + } + final m = RegExp(r'^\[\s*\d+\]\s+(.+?)\s{2,}(ALLOW|DENY|LIMIT|REJECT)\s').firstMatch(t); + if (m != null) { + rules.add(FirewallRule(description: t, action: m.group(2), chain: null)); + } + } + return FirewallStatus( + type: FirewallType.ufw, + enabled: enabled, + defaultInboundPolicy: defaultPolicy, + rules: rules, + ); + } + + static FirewallStatus _parseIptables(String output) { + String? defaultPolicy; + final rules = []; + bool inFilter = false; + for (final line in output.split('\n')) { + final t = line.trim(); + if (t == '*filter') { + inFilter = true; + continue; + } + if (t == 'COMMIT') { + inFilter = false; + continue; + } + if (!inFilter) continue; + final chain = RegExp(r'^:INPUT (\w+)').firstMatch(t); + if (chain != null) defaultPolicy = chain.group(1); + final rule = RegExp(r'^-A (\w+) .+ -j (\w+)').firstMatch(t); + if (rule != null) { + rules.add(FirewallRule( + description: t, + action: rule.group(2), + chain: rule.group(1), + )); + } + } + return FirewallStatus( + type: FirewallType.iptables, + enabled: true, + defaultInboundPolicy: defaultPolicy, + rules: rules, + ); + } + + static FirewallStatus _parseNft(String output) { + final policyMatch = RegExp(r'hook input[^;]*;\s*policy (\w+);').firstMatch(output); + final defaultPolicy = policyMatch?.group(1)?.toUpperCase(); + final rules = []; + for (final line in output.split('\n')) { + final t = line.trim(); + if (t.isEmpty || + t.startsWith('table') || + t.startsWith('chain') || + t.startsWith('type') || + t == '{' || + t == '}') continue; + if (t.contains('accept') || t.contains('drop') || t.contains('reject')) { + final action = t.contains('accept') + ? 'ACCEPT' + : t.contains('drop') + ? 'DROP' + : 'REJECT'; + rules.add(FirewallRule(description: t, action: action, chain: 'input')); + } + } + return FirewallStatus( + type: FirewallType.nftables, + enabled: true, + defaultInboundPolicy: defaultPolicy, + rules: rules, + ); + } +} + +class FirewallRule { + final String description; + final String? action; + final String? chain; + + const FirewallRule({ + required this.description, + this.action, + this.chain, + }); +} diff --git a/app/test/models/firewall_status_test.dart b/app/test/models/firewall_status_test.dart new file mode 100644 index 00000000..a4dd8645 --- /dev/null +++ b/app/test/models/firewall_status_test.dart @@ -0,0 +1,85 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:yourssh/models/firewall_status.dart'; + +void main() { + group('FirewallStatus.fromShellOutput', () { + test('parses active ufw with deny default and rules', () { + const output = ''' +Status: active +Logging: on (low) +Default: deny (incoming), allow (outgoing), disabled (routed) +New profiles: skip + + To Action From + -- ------ ---- +[ 1] 22/tcp ALLOW IN Anywhere +[ 2] 80/tcp ALLOW IN Anywhere +[ 3] 22/tcp (v6) ALLOW IN Anywhere (v6) +'''; + final s = FirewallStatus.fromShellOutput(output); + expect(s.type, FirewallType.ufw); + expect(s.enabled, isTrue); + expect(s.defaultInboundPolicy, 'DENY'); + expect(s.rules.length, 3); + expect(s.rules.first.action, 'ALLOW'); + }); + + test('parses inactive ufw', () { + final s = FirewallStatus.fromShellOutput('Status: inactive'); + expect(s.type, FirewallType.ufw); + expect(s.enabled, isFalse); + expect(s.rules, isEmpty); + }); + + test('parses iptables-save with DROP default', () { + const output = ''' +# Generated by iptables-save +*filter +:INPUT DROP [0:0] +:FORWARD DROP [0:0] +:OUTPUT ACCEPT [0:0] +-A INPUT -m state --state RELATED,ESTABLISHED -j ACCEPT +-A INPUT -p tcp --dport 22 -j ACCEPT +-A INPUT -p tcp --dport 80 -j ACCEPT +COMMIT +'''; + final s = FirewallStatus.fromShellOutput(output); + expect(s.type, FirewallType.iptables); + expect(s.enabled, isTrue); + expect(s.defaultInboundPolicy, 'DROP'); + final inputRules = s.rules.where((r) => r.chain == 'INPUT').toList(); + expect(inputRules.length, 3); + expect(inputRules.last.action, 'ACCEPT'); + }); + + test('parses nft ruleset', () { + const output = ''' +table inet filter { + chain input { + type filter hook input priority 0; policy drop; + ct state established,related accept + iif "lo" accept + tcp dport 22 accept + } +} +'''; + final s = FirewallStatus.fromShellOutput(output); + expect(s.type, FirewallType.nftables); + expect(s.enabled, isTrue); + expect(s.defaultInboundPolicy, 'DROP'); + expect(s.rules.isNotEmpty, isTrue); + }); + + test('returns none for __NO_FIREWALL__ sentinel', () { + final s = FirewallStatus.fromShellOutput('__NO_FIREWALL__\n'); + expect(s.type, FirewallType.none); + expect(s.enabled, isFalse); + expect(s.rules, isEmpty); + }); + + test('returns none for unrecognized output', () { + final s = FirewallStatus.fromShellOutput('some random text'); + expect(s.type, FirewallType.none); + }); + }); +} From 296bb58bbf83e8b00daab2795731ec668c922937 Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Mon, 8 Jun 2026 08:23:18 +0700 Subject: [PATCH 17/59] =?UTF-8?q?feat(monitor):=20SystemStatsService=20?= =?UTF-8?q?=E2=80=94=205s=20polling=20via=20SSH=20exec?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/lib/services/system_stats_service.dart | 47 ++++++++ .../services/system_stats_service_test.dart | 106 ++++++++++++++++++ 2 files changed, 153 insertions(+) create mode 100644 app/lib/services/system_stats_service.dart create mode 100644 app/test/services/system_stats_service_test.dart diff --git a/app/lib/services/system_stats_service.dart b/app/lib/services/system_stats_service.dart new file mode 100644 index 00000000..6f4de31a --- /dev/null +++ b/app/lib/services/system_stats_service.dart @@ -0,0 +1,47 @@ +import 'dart:async'; +import '../models/host.dart'; +import '../models/system_snapshot.dart'; +import 'ssh_service.dart'; + +class SystemStatsService { + Timer? _timer; + final Host host; + final SshService sshService; + final void Function(SystemSnapshot) onUpdate; + + SystemStatsService({ + required this.host, + required this.sshService, + required this.onUpdate, + }); + + void start({Duration interval = const Duration(seconds: 5)}) { + _timer?.cancel(); + _timer = Timer.periodic(interval, (_) => poll()); + } + + void stop() { + _timer?.cancel(); + _timer = null; + } + + /// Fires one poll cycle immediately. Exposed for tests. + Future poll() async { + try { + final result = await sshService.exec(host, _kCommand, auditSource: null); + if (result.stdout.isEmpty) return; + onUpdate(SystemSnapshot.fromShellOutput(result.stdout)); + } catch (_) {} + } +} + +// Raw string concatenation — compiled to a single constant. +// The shell reads /proc/stat twice 200ms apart to compute CPU delta in one exec. +const _kCommand = + r'c1=$(grep -m1 "^cpu " /proc/stat 2>/dev/null); sleep 0.2; ' + r'c2=$(grep -m1 "^cpu " /proc/stat 2>/dev/null); ' + r'printf "__CPU1__\n%s\n__CPU2__\n%s\n" "$c1" "$c2"; ' + r'printf "__MEM__\n"; cat /proc/meminfo 2>/dev/null; ' + r'printf "__DISK__\n"; df -k 2>/dev/null; ' + r'printf "__UPTIME__\n"; cat /proc/uptime 2>/dev/null; ' + r'printf "__PORTS__\n"; ss -tulpn 2>/dev/null || netstat -tulpn 2>/dev/null'; diff --git a/app/test/services/system_stats_service_test.dart b/app/test/services/system_stats_service_test.dart new file mode 100644 index 00000000..db64c574 --- /dev/null +++ b/app/test/services/system_stats_service_test.dart @@ -0,0 +1,106 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:yourssh/models/host.dart'; +import 'package:yourssh/models/system_snapshot.dart'; +import 'package:yourssh/services/ssh_service.dart'; +import 'package:yourssh/services/system_stats_service.dart'; + +class _FakeSsh extends Fake implements SshService { + final String stdout; + int callCount = 0; + + _FakeSsh(this.stdout); + + @override + Future<({String stdout, String stderr, int exitCode})> exec( + Host host, + String command, { + String? auditSource = 'app', + }) async { + callCount++; + return (stdout: stdout, stderr: '', exitCode: 0); + } +} + +class _ThrowingSsh extends Fake implements SshService { + @override + Future<({String stdout, String stderr, int exitCode})> exec( + Host host, + String command, { + String? auditSource = 'app', + }) async { + throw Exception('disconnected'); + } +} + +Host _host() => Host( + id: 'h1', + label: 'test', + host: 'example.com', + port: 22, + username: 'root', + ); + +const _kOutput = ''' +__CPU1__ +cpu 100 0 0 900 0 0 0 0 0 0 +__CPU2__ +cpu 110 0 0 910 0 0 0 0 0 0 +__MEM__ +MemTotal: 2048000 kB +MemAvailable: 1024000 kB +__DISK__ +Filesystem 1K-blocks Used Available Use% Mounted on +/dev/sda1 100000 50000 50000 50% / +__UPTIME__ +3600.0 1800.0 +__PORTS__ +'''; + +void main() { + group('SystemStatsService', () { + test('poll delivers parsed snapshot via onUpdate', () async { + SystemSnapshot? got; + final svc = SystemStatsService( + host: _host(), + sshService: _FakeSsh(_kOutput), + onUpdate: (s) => got = s, + ); + await svc.poll(); + expect(got, isNotNull); + expect(got!.disks.length, 1); + expect(got!.uptime, const Duration(hours: 1)); + expect(got!.totalMemBytes, 2048000 * 1024); + }); + + test('poll silently ignores exec exceptions', () async { + final svc = SystemStatsService( + host: _host(), + sshService: _ThrowingSsh(), + onUpdate: (_) => fail('should not call onUpdate'), + ); + await expectLater(svc.poll(), completes); + }); + + test('poll not called before start()', () async { + final ssh = _FakeSsh(_kOutput); + SystemStatsService(host: _host(), sshService: ssh, onUpdate: (_) {}); + await Future.delayed(const Duration(milliseconds: 20)); + expect(ssh.callCount, 0); + }); + + test('stop() cancels the timer', () async { + final ssh = _FakeSsh(_kOutput); + final svc = SystemStatsService( + host: _host(), + sshService: ssh, + onUpdate: (_) {}, + ); + svc.start(interval: const Duration(milliseconds: 10)); + await Future.delayed(const Duration(milliseconds: 35)); + svc.stop(); + final countAfterStop = ssh.callCount; + await Future.delayed(const Duration(milliseconds: 30)); + expect(ssh.callCount, countAfterStop); + }); + }); +} From abbc77bebb571221b8eab322ccad1230befcee9e Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Mon, 8 Jun 2026 08:23:49 +0700 Subject: [PATCH 18/59] =?UTF-8?q?feat(monitor):=20FirewallStatusService=20?= =?UTF-8?q?=E2=80=94=2030s=20polling=20for=20ufw/iptables/nftables?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/lib/services/firewall_status_service.dart | 41 ++++++++++ .../firewall_status_service_test.dart | 75 +++++++++++++++++++ 2 files changed, 116 insertions(+) create mode 100644 app/lib/services/firewall_status_service.dart create mode 100644 app/test/services/firewall_status_service_test.dart diff --git a/app/lib/services/firewall_status_service.dart b/app/lib/services/firewall_status_service.dart new file mode 100644 index 00000000..169219a8 --- /dev/null +++ b/app/lib/services/firewall_status_service.dart @@ -0,0 +1,41 @@ +import 'dart:async'; +import '../models/firewall_status.dart'; +import '../models/host.dart'; +import 'ssh_service.dart'; + +class FirewallStatusService { + Timer? _timer; + final Host host; + final SshService sshService; + final void Function(FirewallStatus) onUpdate; + + FirewallStatusService({ + required this.host, + required this.sshService, + required this.onUpdate, + }); + + void start({Duration interval = const Duration(seconds: 30)}) { + _timer?.cancel(); + _timer = Timer.periodic(interval, (_) => poll()); + } + + void stop() { + _timer?.cancel(); + _timer = null; + } + + /// Fires one poll cycle immediately. Exposed for tests. + Future poll() async { + try { + final result = await sshService.exec(host, _kCommand, auditSource: null); + onUpdate(FirewallStatus.fromShellOutput(result.stdout)); + } catch (_) {} + } +} + +const _kCommand = + 'ufw status numbered 2>/dev/null || ' + 'iptables-save 2>/dev/null || ' + 'nft list ruleset 2>/dev/null || ' + 'echo __NO_FIREWALL__'; diff --git a/app/test/services/firewall_status_service_test.dart b/app/test/services/firewall_status_service_test.dart new file mode 100644 index 00000000..47088751 --- /dev/null +++ b/app/test/services/firewall_status_service_test.dart @@ -0,0 +1,75 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:yourssh/models/firewall_status.dart'; +import 'package:yourssh/models/host.dart'; +import 'package:yourssh/services/firewall_status_service.dart'; +import 'package:yourssh/services/ssh_service.dart'; + +class _FakeSsh extends Fake implements SshService { + final String stdout; + _FakeSsh(this.stdout); + + @override + Future<({String stdout, String stderr, int exitCode})> exec( + Host host, + String command, { + String? auditSource = 'app', + }) async => + (stdout: stdout, stderr: '', exitCode: 0); +} + +class _ThrowingSsh extends Fake implements SshService { + @override + Future<({String stdout, String stderr, int exitCode})> exec( + Host host, + String command, { + String? auditSource = 'app', + }) async => + throw Exception('err'); +} + +Host _host() => Host( + id: 'h1', + label: 'test', + host: 'example.com', + port: 22, + username: 'root', + ); + +void main() { + group('FirewallStatusService', () { + test('poll delivers parsed FirewallStatus via onUpdate', () async { + FirewallStatus? got; + final svc = FirewallStatusService( + host: _host(), + sshService: _FakeSsh( + 'Status: active\nDefault: deny (incoming), allow (outgoing)\n', + ), + onUpdate: (f) => got = f, + ); + await svc.poll(); + expect(got, isNotNull); + expect(got!.type, FirewallType.ufw); + expect(got!.enabled, isTrue); + }); + + test('poll silently ignores exec exceptions', () async { + final svc = FirewallStatusService( + host: _host(), + sshService: _ThrowingSsh(), + onUpdate: (_) => fail('should not call'), + ); + await expectLater(svc.poll(), completes); + }); + + test('poll delivers none type for __NO_FIREWALL__', () async { + FirewallStatus? got; + final svc = FirewallStatusService( + host: _host(), + sshService: _FakeSsh('__NO_FIREWALL__'), + onUpdate: (f) => got = f, + ); + await svc.poll(); + expect(got!.type, FirewallType.none); + }); + }); +} From c3b1a0f27e5b741414afc62146d84707ae390a11 Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Mon, 8 Jun 2026 08:25:07 +0700 Subject: [PATCH 19/59] =?UTF-8?q?feat(monitor):=20ServerMonitorSheet=20?= =?UTF-8?q?=E2=80=94=20draggable=20bottom=20sheet=20with=20CPU/mem/disk/po?= =?UTF-8?q?rts/firewall?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/lib/widgets/server_monitor_sheet.dart | 372 ++++++++++++++++++ .../widgets/server_monitor_sheet_test.dart | 117 ++++++ 2 files changed, 489 insertions(+) create mode 100644 app/lib/widgets/server_monitor_sheet.dart create mode 100644 app/test/widgets/server_monitor_sheet_test.dart diff --git a/app/lib/widgets/server_monitor_sheet.dart b/app/lib/widgets/server_monitor_sheet.dart new file mode 100644 index 00000000..055b537c --- /dev/null +++ b/app/lib/widgets/server_monitor_sheet.dart @@ -0,0 +1,372 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../models/firewall_status.dart'; +import '../models/host.dart'; +import '../models/system_snapshot.dart'; +import '../providers/session_provider.dart'; +import '../services/firewall_status_service.dart'; +import '../services/ssh_service.dart'; +import '../services/system_stats_service.dart'; +import '../theme/app_theme.dart'; + +class ServerMonitorSheet extends StatefulWidget { + final Host host; + // Bypasses the SessionProvider check in tests — null means use the real check. + @visibleForTesting + final bool? testIsConnected; + + const ServerMonitorSheet({super.key, required this.host, this.testIsConnected}); + + static void show(BuildContext context, Host host) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (_) => ServerMonitorSheet(host: host), + ); + } + + @override + State createState() => ServerMonitorSheetState(); +} + +// Public so widget tests can cast via tester.state(). +class ServerMonitorSheetState extends State { + SystemStatsService? _statsService; + FirewallStatusService? _firewallService; + SystemSnapshot? _snapshot; + FirewallStatus? _firewall; + bool _started = false; + + @visibleForTesting + void debugSetSnapshot(SystemSnapshot s) => setState(() => _snapshot = s); + + @visibleForTesting + void debugSetFirewall(FirewallStatus f) => setState(() => _firewall = f); + + bool _isConnected(BuildContext context) => + widget.testIsConnected ?? + context + .read() + .sshSessions + .any((s) => s.host.id == widget.host.id); + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (_started) return; + _started = true; + if (!_isConnected(context)) return; + final ssh = context.read(); + _statsService = SystemStatsService( + host: widget.host, + sshService: ssh, + onUpdate: (s) { + if (mounted) setState(() => _snapshot = s); + }, + ); + _firewallService = FirewallStatusService( + host: widget.host, + sshService: ssh, + onUpdate: (f) { + if (mounted) setState(() => _firewall = f); + }, + ); + _statsService!.start(); + _firewallService!.start(); + // Deliver first reading immediately rather than waiting for the first tick. + _statsService!.poll(); + _firewallService!.poll(); + } + + @override + void dispose() { + _statsService?.stop(); + _firewallService?.stop(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final isConnected = _isConnected(context); + return DraggableScrollableSheet( + initialChildSize: 0.6, + minChildSize: 0.4, + maxChildSize: 0.95, + builder: (_, ctrl) => Container( + decoration: const BoxDecoration( + color: Color(0xFF1A1A1A), + borderRadius: BorderRadius.vertical(top: Radius.circular(12)), + border: Border(top: BorderSide(color: Color(0xFF2A2A2A))), + ), + child: Column(children: [ + _handle(), + _header(), + Expanded( + child: isConnected ? _body(ctrl) : _notConnected(), + ), + ]), + ), + ); + } + + Widget _handle() => Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Container( + width: 36, + height: 4, + decoration: BoxDecoration( + color: const Color(0xFF3A3A3A), + borderRadius: BorderRadius.circular(2), + ), + ), + ); + + Widget _header() => Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), + child: Row(children: [ + Text( + widget.host.label, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + const Spacer(), + if (_snapshot != null) + Row(children: [ + Container( + width: 7, + height: 7, + decoration: const BoxDecoration( + color: AppColors.accent, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 5), + const Text( + 'Live', + style: TextStyle(color: AppColors.accent, fontSize: 11), + ), + ]), + ]), + ); + + Widget _notConnected() => const Center( + child: Text( + 'No active session — open a terminal first', + style: TextStyle(color: AppColors.textSecondary), + ), + ); + + Widget _body(ScrollController ctrl) => ListView( + controller: ctrl, + padding: const EdgeInsets.fromLTRB(16, 0, 16, 24), + children: [ + _sectionTitle('SYSTEM'), + _snapshot == null + ? const Center(child: CircularProgressIndicator()) + : _systemSection(_snapshot!), + const SizedBox(height: 16), + _sectionTitle('PORTS'), + _snapshot == null + ? const Center(child: CircularProgressIndicator()) + : _portsSection(_snapshot!.ports), + const SizedBox(height: 16), + _sectionTitle('FIREWALL'), + _firewall == null + ? const Center(child: CircularProgressIndicator()) + : _firewallSection(_firewall!), + ], + ); + + Widget _sectionTitle(String t) => Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text( + t, + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 11, + letterSpacing: 0.8, + ), + ), + ); + + Widget _systemSection(SystemSnapshot s) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _statRow('Uptime', SystemSnapshot.formatUptime(s.uptime)), + const SizedBox(height: 6), + _barRow( + 'CPU', + s.cpuPercent / 100, + '${s.cpuPercent.toStringAsFixed(1)}%', + ), + const SizedBox(height: 6), + _barRow( + 'Memory', + s.totalMemBytes == 0 ? 0 : s.usedMemBytes / s.totalMemBytes, + '${SystemSnapshot.formatBytes(s.usedMemBytes)} / ' + '${SystemSnapshot.formatBytes(s.totalMemBytes)}', + ), + ...s.disks.map((d) => Padding( + padding: const EdgeInsets.only(top: 6), + child: _barRow( + d.mountPoint, + d.usedPercent, + '${(d.usedPercent * 100).toStringAsFixed(0)}% of ' + '${SystemSnapshot.formatBytes(d.totalKb * 1024)}', + ), + )), + ], + ); + + Widget _statRow(String label, String value) => Row(children: [ + SizedBox( + width: 72, + child: Text( + label, + style: const TextStyle(color: AppColors.textSecondary, fontSize: 12), + ), + ), + Text( + value, + style: const TextStyle(color: AppColors.textPrimary, fontSize: 12), + ), + ]); + + Widget _barRow(String label, double fraction, String right) => Row(children: [ + SizedBox( + width: 72, + child: Text( + label, + style: const TextStyle(color: AppColors.textSecondary, fontSize: 12), + overflow: TextOverflow.ellipsis, + ), + ), + Expanded( + child: ClipRRect( + borderRadius: BorderRadius.circular(2), + child: LinearProgressIndicator( + value: fraction.clamp(0.0, 1.0), + minHeight: 6, + backgroundColor: const Color(0xFF2A2A2A), + color: fraction > 0.85 ? AppColors.red : AppColors.accent, + ), + ), + ), + const SizedBox(width: 8), + Text( + right, + style: const TextStyle(color: AppColors.textSecondary, fontSize: 11), + ), + ]); + + Widget _portsSection(List ports) { + if (ports.isEmpty) { + return const Text( + 'No listening ports detected', + style: TextStyle(color: AppColors.textSecondary, fontSize: 12), + ); + } + return Column( + children: ports + .map((p) => Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row(children: [ + SizedBox( + width: 36, + child: Text( + p.protocol, + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 11, + fontFamily: 'monospace', + ), + ), + ), + SizedBox( + width: 72, + child: Text( + ':${p.localPort}', + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 12, + fontFamily: 'monospace', + ), + ), + ), + Expanded( + child: Text( + p.process ?? '—', + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 11, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ]), + )) + .toList(), + ); + } + + Widget _firewallSection(FirewallStatus fw) { + if (fw.type == FirewallType.none) { + return const Text( + 'No firewall detected', + style: TextStyle(color: AppColors.textSecondary, fontSize: 12), + ); + } + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row(children: [ + _chip(fw.type.name, AppColors.accent), + const SizedBox(width: 6), + _chip( + fw.enabled ? 'active' : 'inactive', + fw.enabled ? AppColors.accent : AppColors.red, + ), + if (fw.defaultInboundPolicy != null) ...[ + const SizedBox(width: 8), + Text( + 'default inbound: ${fw.defaultInboundPolicy}', + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 11, + ), + ), + ], + ]), + if (fw.rules.isNotEmpty) ...[ + const SizedBox(height: 8), + ...fw.rules.map((r) => Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Text( + r.description, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 11, + fontFamily: 'monospace', + ), + overflow: TextOverflow.ellipsis, + ), + )), + ], + ], + ); + } + + Widget _chip(String label, Color color) => Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(4), + border: Border.all(color: color.withValues(alpha: 0.3)), + ), + child: Text(label, style: TextStyle(color: color, fontSize: 11)), + ); +} diff --git a/app/test/widgets/server_monitor_sheet_test.dart b/app/test/widgets/server_monitor_sheet_test.dart new file mode 100644 index 00000000..125add2a --- /dev/null +++ b/app/test/widgets/server_monitor_sheet_test.dart @@ -0,0 +1,117 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:yourssh/models/firewall_status.dart'; +import 'package:yourssh/models/host.dart'; +import 'package:yourssh/models/system_snapshot.dart'; +import 'package:yourssh/services/ssh_service.dart'; +import 'package:yourssh/widgets/server_monitor_sheet.dart'; + +class _FakeSsh extends Fake implements SshService {} + +Widget _wrap(Widget child) => MultiProvider( + providers: [ + Provider(create: (_) => _FakeSsh()), + ], + child: MaterialApp(home: Scaffold(body: child)), + ); + +Host _host() => Host( + id: 'h1', + label: 'ubuntu-prod', + host: 'example.com', + port: 22, + username: 'root', + ); + +void main() { + group('ServerMonitorSheet', () { + testWidgets('shows not-connected message when testIsConnected is false', + (tester) async { + await tester.pumpWidget( + _wrap(ServerMonitorSheet(host: _host(), testIsConnected: false)), + ); + expect(find.textContaining('No active session'), findsOneWidget); + }); + + testWidgets('shows loading indicators while awaiting first snapshot', + (tester) async { + await tester.pumpWidget( + _wrap(ServerMonitorSheet(host: _host(), testIsConnected: true)), + ); + expect(find.byType(CircularProgressIndicator), findsWidgets); + }); + + testWidgets('renders system section after debugSetSnapshot', (tester) async { + await tester.pumpWidget( + _wrap(ServerMonitorSheet(host: _host(), testIsConnected: true)), + ); + final state = tester.state( + find.byType(ServerMonitorSheet), + ); + state.debugSetSnapshot(SystemSnapshot( + cpuPercent: 42.5, + totalMemBytes: 8 * 1024 * 1024 * 1024, + usedMemBytes: 3 * 1024 * 1024 * 1024, + disks: [ + DiskMount( + source: '/dev/sda1', + mountPoint: '/', + totalKb: 100000, + usedKb: 45000), + ], + uptime: const Duration(hours: 14, minutes: 3), + ports: [ + PortEntry( + protocol: 'tcp', + localAddress: '0.0.0.0', + localPort: 22, + process: 'sshd'), + ], + timestamp: DateTime.now(), + )); + await tester.pump(); + expect(find.textContaining('42'), findsOneWidget); + expect(find.textContaining('sshd'), findsOneWidget); + }); + + testWidgets('renders firewall section after debugSetFirewall', + (tester) async { + await tester.pumpWidget( + _wrap(ServerMonitorSheet(host: _host(), testIsConnected: true)), + ); + final state = tester.state( + find.byType(ServerMonitorSheet), + ); + state.debugSetFirewall(const FirewallStatus( + type: FirewallType.ufw, + enabled: true, + defaultInboundPolicy: 'DENY', + rules: [ + FirewallRule( + description: '22/tcp ALLOW anywhere', action: 'ALLOW'), + ], + )); + await tester.pump(); + expect(find.textContaining('ufw'), findsOneWidget); + expect(find.textContaining('DENY'), findsOneWidget); + }); + + testWidgets('renders no-firewall message for FirewallType.none', + (tester) async { + await tester.pumpWidget( + _wrap(ServerMonitorSheet(host: _host(), testIsConnected: true)), + ); + final state = tester.state( + find.byType(ServerMonitorSheet), + ); + state.debugSetFirewall(const FirewallStatus( + type: FirewallType.none, + enabled: false, + rules: [], + )); + await tester.pump(); + expect(find.textContaining('No firewall'), findsOneWidget); + }); + }); +} From f950fd488ef2320f53acbfd91df65a9c0d2bc70e Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Mon, 8 Jun 2026 08:26:43 +0700 Subject: [PATCH 20/59] feat(monitor): wire ServerMonitorSheet into host card hover button + context menu --- app/lib/models/firewall_status.dart | 4 +++- app/lib/widgets/hosts_dashboard.dart | 9 +++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/app/lib/models/firewall_status.dart b/app/lib/models/firewall_status.dart index 433d74ce..2eb42672 100644 --- a/app/lib/models/firewall_status.dart +++ b/app/lib/models/firewall_status.dart @@ -102,7 +102,9 @@ class FirewallStatus { t.startsWith('chain') || t.startsWith('type') || t == '{' || - t == '}') continue; + t == '}') { + continue; + } if (t.contains('accept') || t.contains('drop') || t.contains('reject')) { final action = t.contains('accept') ? 'ACCEPT' diff --git a/app/lib/widgets/hosts_dashboard.dart b/app/lib/widgets/hosts_dashboard.dart index 94e3575d..406522f3 100644 --- a/app/lib/widgets/hosts_dashboard.dart +++ b/app/lib/widgets/hosts_dashboard.dart @@ -23,6 +23,7 @@ import 'rdp_badge.dart'; import 'bulk/bulk_action_bar.dart'; import 'bulk/bulk_push_dialog.dart'; import 'bulk/bulk_run_dialog.dart'; +import 'server_monitor_sheet.dart'; import 'sftp_screen.dart'; class HostsDashboard extends StatefulWidget { @@ -1051,6 +1052,11 @@ class _HostCardState extends State<_HostCard> { const SizedBox(width: 2), _iconBtn(Icons.folder_outlined, 'SFTP', onTap: () => _openSftp(context)), const SizedBox(width: 2), + if (context.read().sshSessions.any((s) => s.host.id == widget.host.id)) ...[ + _iconBtn(Icons.monitor_heart_outlined, 'Monitor', + onTap: () => ServerMonitorSheet.show(context, widget.host)), + const SizedBox(width: 2), + ], ], _iconBtn(Icons.more_horiz, 'More', onTapDown: (d) => _showMenu(context, d.globalPosition)), ], @@ -1223,6 +1229,9 @@ class _HostCardState extends State<_HostCard> { _menuItem('terminal', Icons.terminal, 'Connect', () => sessionProvider.connectAny(widget.host)), if (isSsh) _menuItem('sftp', Icons.folder_outlined, 'SFTP', () => _openSftp(context)), + if (isSsh) + _menuItem('monitor', Icons.monitor_heart_outlined, 'Monitor', + () => ServerMonitorSheet.show(context, widget.host)), _menuItem('edit', Icons.edit_outlined, 'Edit', () => widget.onEditHost?.call(widget.host)), const PopupMenuDivider(), _menuItem('duplicate', Icons.copy_outlined, 'Duplicate', () => _duplicate(context, hostProvider)), From fe147849f32dcca03fcefac85a3fce1776f7c83e Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Mon, 8 Jun 2026 12:29:47 +0700 Subject: [PATCH 21/59] docs(roadmap): mark keyword highlighting + server monitor panel as shipped --- docs/roadmap.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index dcb82b67..7a277ac8 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,11 +1,11 @@ # YourSSH — Roadmap > Direction: **infra workstation for DevOps/SRE managing 10–100+ hosts**, not just an SSH client. -> Current version: 0.1.33 · updated: 2026-06-07 (release 0.1.33: in-app RDP client — IronRDP engine, SSH-tunneled RDP, pre-auth cert pinning, fullscreen) +> Current version: 0.1.33 · updated: 2026-06-08 (develop: keyword highlighting, server monitor panel) This document lists proposed features ordered by priority. Each item can be broken out into its own spec (`docs/superpowers/specs/`) when ready for implementation. -Already shipped (not repeated in roadmap): multi-tab terminal, split view, broadcast, recording (asciicast), snippet, SFTP dual-panel, port forwarding, jump host, Supabase sync + P2P LAN, AI chat sidebar with tool calling, plugin system (DevOps / WebTools / Snippets), Cloudflare tunnel, MCP gateway, mail catcher, code editor (Monaco), customizable hotkeys, TOFU known-hosts, **Command Palette (Cmd/Ctrl+K)** — fuzzy search hosts / nav / snippets / actions, **Workspace persistence** — auto-reconnect tabs + layout on relaunch, **Search-in-scrollback (Cmd/Ctrl+F)** — regex, highlights, prev/next navigation, **Script Engine plugin system** — disk-based JS plugins via QuickJS FFI, HookBus (terminal.output / terminal.input / session events), SSH/SFTP/Storage/UI bridges, hot-reload file watcher, PermissionGuard + circuit breaker, consent dialog, manager screen + console log viewer, **Import** — paste SSH config / JSON / CSV with per-host include toggles (`parseSshConfig` in `import_panel.dart`), **Host tagging** — comma-separated tags on the `Host` model, editable in host detail and searchable from the dashboard, **Smart filter + multi-dimensional query (0.1.10)** — `HostQuery` parser with `key:value` faceted AND/OR semantics, toggleable facet chips on hosts dashboard, tag-based search, **Terminal sharing / multiplayer (0.1.13)** — share a live SSH session via Supabase Realtime; guests join with a session code, watch or interact in real time; `ShareSessionService`, `ShareProvider`, `ShareEvent`, split-view watch banner, **Advanced tab management** — rename, color tag, pin, drag reorder; all tab metadata persists per host, **Connection health badge (0.1.17)** — live latency-driven dot per session tab (green/amber/red/grey + pulse), hover tooltip with uptime / last-ping / reconnect count; `HealthMonitorService` pings the live channel (`SSHClient.ping`) as sole pinger, 5s timeout surfaces half-open silent drops, **Shell integration (0.1.18)** — injected bash/zsh prompt-hooks emit OSC 7 + OSC 133 captured via xterm `onPrivateOSC`; cwd on the session tab, per-command status gutter (green/red/grey), jump-to-prompt (`Cmd/Ctrl+↑/↓`), and cwd-aware path autocomplete in the input bar; auto-on with per-host + global opt-out, **In-app updates (0.1.19)** — checks GitHub `releases/latest` on launch (24h debounce) + manual check in Settings; since 0.1.29 also re-checks while the app stays running (6h periodic timer + window-focus check, same 24h debounce) so the notification bell picks up new releases without a restart; dismissible update banner + Settings Updates section; downloads the correct OS/arch artifact and hands off to the OS installer (assisted flow, unsigned-app friendly: macOS strips quarantine + opens the DMG, Windows runs the installer, Linux opens the package); falls back to the Releases page when no artifact matches (e.g. Intel Mac); `UpdateService` + `UpdateProvider` + `UpdateBanner`, **Sudo SFTP (0.1.20)** — per-host SFTP mode running the entire SFTP session as root through `sudo` over an exec channel (WinSCP-style); distro auto-detection, `NOPASSWD` guidance, root badge on elevated panels, **External edit + Open with… (0.1.20–0.1.21)** — open remote files with the OS default app or any installed app (hover submenu; per-OS discovery via `NSWorkspace` / XDG `.desktop` / Windows registry); the local copy is watched and auto-uploaded on every save; plain-text editor fallback where Monaco is unavailable, **SFTP View mode (0.1.21)** — read-only preview separate from Edit, **Invisible shell-integration injection (0.1.21)** — bracketed-paste readiness detection + `read -rs` two-phase handshake delivers the OSC hook installer without ever echoing into the terminal or recordings, **Terminal snippets panel (0.1.21)** — collapsible right-side panel in the terminal workspace to browse/search/copy/run snippets against the active pane, backed by the new plugin `sendInput` API, **Unified terminal tabs (0.1.24)** — local shell sessions are first-class tabs in the global top bar (`TerminalSession` model), split into panes alongside SSH, recordable to asciicast, **SFTP two-panel with switchable sources (0.1.24)** — per-panel Local/host source chip, unified headers (filter + actions), clickable breadcrumbs (#41), workspace kept alive across tab switches (#42), **Terminal copy/paste UX (0.1.24)** — selection-gated Ctrl+C copy (SIGINT preserved), Ctrl(+Shift)+V paste, right-click Copy/Paste/Select All menu, middle-click paste (#43), readable semi-transparent selection colors in all themes (#40), **Notification bell (0.1.25)** — bell in the top tab bar with unread badge + anchored popover; update-available items carry a one-click Update button, unexpected session drops (no pending auto-reconnect) are collected per session; mark-read on open, per-item dismiss, clear all; in-memory `NotificationCenterProvider` (deduped, capped at 50), **Terminal appearance side panel** — tune icon in the terminal toolbar opens a right-side panel (mutually exclusive with the snippets panel via the `SidePanel` enum; shared `WorkspaceSidePanel` frame) to change color theme, font size (live preview while dragging, persisted once on release), and font family without leaving the workspace; controls shared with Settings → Terminal via `TerminalAppearanceControls`, **Terminal theme catalog 35 → 44** — added Kanagawa Dragon/Lotus, Tokyo Night Day, Nord Light, Light Owl, Flexoki Dark/Light, Aura, and Cyberpunk from their authors' published palettes, grouped next to their families in the picker, with visibility-tuned cursor/selection/search colors on the light variants, **Port forwarding runtime (0.1.27)** — saved local/remote/dynamic SOCKS5 rules actually start and stop (`PortForwardService` over the dartssh2 forward APIs behind a testable `TunnelTransport` abstraction); tunnels reuse the host's open SSH client or auto-connect with stored credentials (no terminal tab required), auto-reconnect with 2 s → 30 s exponential backoff keeping local listeners bound across drops, per-rule edit panel, auto-start on launch, live connection counters, inline error reporting, **SSH Agent Forwarding (0.1.28, #49)** — per-host toggle (like `ssh -A`); forwarded `auth-agent@openssh.com` channels are served by the local system agent (`SSH_AUTH_SOCK` / Windows OpenSSH agent pipe) with fallback to app-Keychain keys when no system agent is running; requested on shell channels only, server refusal shows a terminal warning instead of killing the session (`AgentForwardingHandler`, `SystemAgentProxy.roundtrip`, dartssh2 fork agent-channel support), **Strict KEX (0.1.29)** — CVE-2023-48795 "Terrapin" mitigation in the dartssh2 fork: sequence numbers reset after every NEWKEYS, non-KEX messages during the initial exchange terminate the connection, KEXINIT must be the first packet, **Quick wins (0.1.29)** — middle-click closes unpinned session tabs; right-click context menu on port-forward rules with Duplicate (new id, "(copy)" label, auto-start off); distro-level OS icons (`/etc/os-release` ID → ubuntu/debian/fedora/centos/rocky/alma/alpine/amazon/arch/suse/redhat glyphs) on the hosts dashboard and SSH session tabs (`os_detection.dart`, `SessionTab` extracted from main_screen); empty-password SSH behavior locked in by tests (blank passwords are never persisted; connect sends ''), **SFTP permissions editor + unified entry context menu (0.1.29)** — chmod dialog (9-checkbox rwx grid two-way synced with a validated octal field: octal-only input, 3–4 digits, Apply gated while invalid; unknown current permissions fall back to stat() then warn and gate Apply instead of offering 000) on both the remote SFTP and local panels; `SftpFileOpsService.chmod` with a hardened recursive walk (entries with omitted modes classified via lstat, symlinks never followed — SETSTAT would chmod the target, directory modes applied post-order so a restrictive mode can't lock the walk out, file chmods batched 8-wide) sharing its `listWalkChildren` classification with recursive delete; st_mode carried on `SftpEntry`/`LocalEntry` from listing/scan time (no blocking stat at dialog-open); one shared `EntryContextMenu` for both panels (Open / Open with / View / Edit / Copy to target with up-front feasibility reasons incl. the same-folder block / Refresh / New folder / Permissions / Rename / Delete) wired through the dual-panel transfer matrix; shared app-launch helpers extracted to `util/app_launcher.dart`, **Bulk action panel (0.1.30)** — SELECT mode on the hosts dashboard (per-card checkboxes, filter-aware Select all, Esc to exit) with Connect all (skips already-connected hosts, confirms before opening more than 5 tabs), Run command in parallel (free text or snippet; bounded concurrency, 30 s per-host timeout, per-host failure isolation; per-host exit code / duration / expandable stdout+stderr; a Diff tab groups identical outputs against a promotable baseline and side-by-side compares any two hosts), and Push files to one remote path on every host (destination created if missing, per-host byte progress, cancel); closing a dialog mid-run confirms, cancels queued hosts and lets in-flight operations record their real result, **Dashboard grid & list view + sorting (0.1.30)** — card grid ↔ compact one-line list toggle and a sort dropdown (name / creation date / hostname, asc/desc), both persisted across restarts; default order Name A–Z, **Agent forwarding observability (0.1.30)** — pre-connect agent status line in the host panel (system agent identities / app-Keychain fallback / nothing to serve), live per-session key icon on the session tab (grey ready / green active / orange Keychain fallback / red refused), and a notification-bell item with tap-to-jump when the server refuses forwarding, **Connection Chain editor (0.1.30)** — Termius-style visual chain replacing the jump-host dropdown in the host panel (bastion card → arrow → destination, searchable Add-a-Host picker, agent-forwarding key icon, Clear; `HostChainEditor`, single hop), **Jump host on auto-connect paths (0.1.30)** — `ensureClient` (SFTP / exec / port forwarding) now resolves `Host.jumpHostId` via `defaultJumpHostLookup`, so hosts behind a bastion tunnel correctly outside interactive sessions; plus `tool/jump_probe.dart`, a layer-by-layer jump diagnostic CLI, **Session template / per-host preset (0.1.32)** — per-host working dir + env vars delivered invisibly via the shell-integration handshake (tty canonical mode lifted during the payload read so large templates don't truncate at the 4096-byte line cap), startup snippet typed visibly after the DONE sentinel (skipped under tmux and on handshake abort — a re-attach must not replay it), and per-host terminal theme / font / size / TERM / tmux overrides falling back to globals (out-of-range synced values rejected at render); SESSION TEMPLATE section in the host panel with env-key validation incl. duplicate-name detection, **Internal audit log (0.1.32)** — local SQLite (`sqlite3`, WAL, `/audit.db`) trail of connect/disconnect/exec/input events with per-caller source tagging (`bulk` / `devops` / `plugin:` / `input-bar` / broadcast targets each get their own row; internal polls like network stats and OS detection are exempt), secret redaction before insert (key=value incl. quoted multi-word values, Bearer tokens, sshpass/mysql-family `-p`, redis-cli `-a`, URL userinfo — applied to meta error strings too), Audit Log screen with type/time/search filters, keyset pagination, CSV/JSON export (macOS sandbox entitlement upgraded to user-selected read-write), retention pruning at launch (default 90 days, configurable in Settings → Audit) and fail-soft writes that can never break an SSH operation, **In-app SSH key generation (0.1.32)** — generate Ed25519 (pure Dart in the dartssh2 fork: `OpenSSHEd25519KeyPair.generate()` + passphrase-encrypting `toPem` via bcrypt-pbkdf + aes256-ctr, interop-verified against real `ssh-keygen -y`) / RSA-4096 / ECDSA-P256 (via system `ssh-keygen`; options gated by a binary probe) from the Keychain screen; keys land in `Documents/YourSSH/keys` mode 600 with the passphrase in secure storage; copy public key + ssh-copy-id-style **deploy-to-host dialog** (idempotent `grep -qxF`, EXISTS/ADDED markers) — includes the deploy stretch goal, **Local shell picker (0.1.32)** — choose which shell local terminal tabs run: auto-detected per platform (Windows: PowerShell, cmd, PowerShell 7, Git Bash, one profile per WSL distro; macOS/Linux: `$SHELL` + `/etc/shells`) plus user-added custom executables with arguments; default in Settings → Terminal, per-session choice in the new-tab (+) menu, Restart shell reuses the session's shell, an uninstalled default falls back to the platform default with a terminal warning (`ShellProfile`, `shell_detection.dart`), **Multi-hop jump chain (0.1.32)** — bastion → bastion → target for layered networks: `Host.jumpHostIds` ordered chain (legacy `jumpHostId` migrates and is dual-written to JSON for cross-version sync), `SshService` dials hop-over-hop via `forwardLocal` with chain-prefix-keyed client cache, deepest-first refcounted teardown and a cycle guard, per-hop host-key verification; `HostChainEditor` appends/removes hops (persistent Add a Host, per-hop ×, Clear); `ensureClient` and test-connection resolve the full chain, **Recording redaction (0.1.32)** — secrets masked before being written to `.cast`: line-buffered redaction in `RecordingService` reusing `AuditRedactor` unchanged (split at the last newline, start-once 500 ms flush timer, stop flushes the tail; per-line coalescing also strips keystroke timing), global Settings toggle AND per-host opt-out (both default on; pure `effectiveRecordingRedaction` policy sampled once at record start with a fresh `HostProvider` lookup), **In-app RDP client (0.1.33, #44)** — Windows / xrdp desktops as first-class tabs: IronRDP Rust engine via flutter_rust_bridge v2 (`packages/yourssh_rdp`), NLA/TLS/auto security, direct or SSH-tunneled through a saved jump host (full connection chain reused), TOFU certificate pinning enforced in Rust pre-CredSSP (changed cert aborts before credentials are sent), server-negotiated resolution handling, bidirectional clipboard, full keyboard/mouse incl. Ctrl+Alt+Del, OS-level fullscreen with mstsc-style hover pill + auto-exit safety, graceful server-initiated disconnect (MCS ultimatum → clean message, not a raw protocol error), protocol-aware dashboard actions, RDP badge, tab parity (rename/color/pin/restore/audit/notification bell). +Already shipped (not repeated in roadmap): multi-tab terminal, split view, broadcast, recording (asciicast), snippet, SFTP dual-panel, port forwarding, jump host, Supabase sync + P2P LAN, AI chat sidebar with tool calling, plugin system (DevOps / WebTools / Snippets), Cloudflare tunnel, MCP gateway, mail catcher, code editor (Monaco), customizable hotkeys, TOFU known-hosts, **Command Palette (Cmd/Ctrl+K)** — fuzzy search hosts / nav / snippets / actions, **Workspace persistence** — auto-reconnect tabs + layout on relaunch, **Search-in-scrollback (Cmd/Ctrl+F)** — regex, highlights, prev/next navigation, **Script Engine plugin system** — disk-based JS plugins via QuickJS FFI, HookBus (terminal.output / terminal.input / session events), SSH/SFTP/Storage/UI bridges, hot-reload file watcher, PermissionGuard + circuit breaker, consent dialog, manager screen + console log viewer, **Import** — paste SSH config / JSON / CSV with per-host include toggles (`parseSshConfig` in `import_panel.dart`), **Host tagging** — comma-separated tags on the `Host` model, editable in host detail and searchable from the dashboard, **Smart filter + multi-dimensional query (0.1.10)** — `HostQuery` parser with `key:value` faceted AND/OR semantics, toggleable facet chips on hosts dashboard, tag-based search, **Terminal sharing / multiplayer (0.1.13)** — share a live SSH session via Supabase Realtime; guests join with a session code, watch or interact in real time; `ShareSessionService`, `ShareProvider`, `ShareEvent`, split-view watch banner, **Advanced tab management** — rename, color tag, pin, drag reorder; all tab metadata persists per host, **Connection health badge (0.1.17)** — live latency-driven dot per session tab (green/amber/red/grey + pulse), hover tooltip with uptime / last-ping / reconnect count; `HealthMonitorService` pings the live channel (`SSHClient.ping`) as sole pinger, 5s timeout surfaces half-open silent drops, **Shell integration (0.1.18)** — injected bash/zsh prompt-hooks emit OSC 7 + OSC 133 captured via xterm `onPrivateOSC`; cwd on the session tab, per-command status gutter (green/red/grey), jump-to-prompt (`Cmd/Ctrl+↑/↓`), and cwd-aware path autocomplete in the input bar; auto-on with per-host + global opt-out, **In-app updates (0.1.19)** — checks GitHub `releases/latest` on launch (24h debounce) + manual check in Settings; since 0.1.29 also re-checks while the app stays running (6h periodic timer + window-focus check, same 24h debounce) so the notification bell picks up new releases without a restart; dismissible update banner + Settings Updates section; downloads the correct OS/arch artifact and hands off to the OS installer (assisted flow, unsigned-app friendly: macOS strips quarantine + opens the DMG, Windows runs the installer, Linux opens the package); falls back to the Releases page when no artifact matches (e.g. Intel Mac); `UpdateService` + `UpdateProvider` + `UpdateBanner`, **Sudo SFTP (0.1.20)** — per-host SFTP mode running the entire SFTP session as root through `sudo` over an exec channel (WinSCP-style); distro auto-detection, `NOPASSWD` guidance, root badge on elevated panels, **External edit + Open with… (0.1.20–0.1.21)** — open remote files with the OS default app or any installed app (hover submenu; per-OS discovery via `NSWorkspace` / XDG `.desktop` / Windows registry); the local copy is watched and auto-uploaded on every save; plain-text editor fallback where Monaco is unavailable, **SFTP View mode (0.1.21)** — read-only preview separate from Edit, **Invisible shell-integration injection (0.1.21)** — bracketed-paste readiness detection + `read -rs` two-phase handshake delivers the OSC hook installer without ever echoing into the terminal or recordings, **Terminal snippets panel (0.1.21)** — collapsible right-side panel in the terminal workspace to browse/search/copy/run snippets against the active pane, backed by the new plugin `sendInput` API, **Unified terminal tabs (0.1.24)** — local shell sessions are first-class tabs in the global top bar (`TerminalSession` model), split into panes alongside SSH, recordable to asciicast, **SFTP two-panel with switchable sources (0.1.24)** — per-panel Local/host source chip, unified headers (filter + actions), clickable breadcrumbs (#41), workspace kept alive across tab switches (#42), **Terminal copy/paste UX (0.1.24)** — selection-gated Ctrl+C copy (SIGINT preserved), Ctrl(+Shift)+V paste, right-click Copy/Paste/Select All menu, middle-click paste (#43), readable semi-transparent selection colors in all themes (#40), **Notification bell (0.1.25)** — bell in the top tab bar with unread badge + anchored popover; update-available items carry a one-click Update button, unexpected session drops (no pending auto-reconnect) are collected per session; mark-read on open, per-item dismiss, clear all; in-memory `NotificationCenterProvider` (deduped, capped at 50), **Terminal appearance side panel** — tune icon in the terminal toolbar opens a right-side panel (mutually exclusive with the snippets panel via the `SidePanel` enum; shared `WorkspaceSidePanel` frame) to change color theme, font size (live preview while dragging, persisted once on release), and font family without leaving the workspace; controls shared with Settings → Terminal via `TerminalAppearanceControls`, **Terminal theme catalog 35 → 44** — added Kanagawa Dragon/Lotus, Tokyo Night Day, Nord Light, Light Owl, Flexoki Dark/Light, Aura, and Cyberpunk from their authors' published palettes, grouped next to their families in the picker, with visibility-tuned cursor/selection/search colors on the light variants, **Port forwarding runtime (0.1.27)** — saved local/remote/dynamic SOCKS5 rules actually start and stop (`PortForwardService` over the dartssh2 forward APIs behind a testable `TunnelTransport` abstraction); tunnels reuse the host's open SSH client or auto-connect with stored credentials (no terminal tab required), auto-reconnect with 2 s → 30 s exponential backoff keeping local listeners bound across drops, per-rule edit panel, auto-start on launch, live connection counters, inline error reporting, **SSH Agent Forwarding (0.1.28, #49)** — per-host toggle (like `ssh -A`); forwarded `auth-agent@openssh.com` channels are served by the local system agent (`SSH_AUTH_SOCK` / Windows OpenSSH agent pipe) with fallback to app-Keychain keys when no system agent is running; requested on shell channels only, server refusal shows a terminal warning instead of killing the session (`AgentForwardingHandler`, `SystemAgentProxy.roundtrip`, dartssh2 fork agent-channel support), **Strict KEX (0.1.29)** — CVE-2023-48795 "Terrapin" mitigation in the dartssh2 fork: sequence numbers reset after every NEWKEYS, non-KEX messages during the initial exchange terminate the connection, KEXINIT must be the first packet, **Quick wins (0.1.29)** — middle-click closes unpinned session tabs; right-click context menu on port-forward rules with Duplicate (new id, "(copy)" label, auto-start off); distro-level OS icons (`/etc/os-release` ID → ubuntu/debian/fedora/centos/rocky/alma/alpine/amazon/arch/suse/redhat glyphs) on the hosts dashboard and SSH session tabs (`os_detection.dart`, `SessionTab` extracted from main_screen); empty-password SSH behavior locked in by tests (blank passwords are never persisted; connect sends ''), **SFTP permissions editor + unified entry context menu (0.1.29)** — chmod dialog (9-checkbox rwx grid two-way synced with a validated octal field: octal-only input, 3–4 digits, Apply gated while invalid; unknown current permissions fall back to stat() then warn and gate Apply instead of offering 000) on both the remote SFTP and local panels; `SftpFileOpsService.chmod` with a hardened recursive walk (entries with omitted modes classified via lstat, symlinks never followed — SETSTAT would chmod the target, directory modes applied post-order so a restrictive mode can't lock the walk out, file chmods batched 8-wide) sharing its `listWalkChildren` classification with recursive delete; st_mode carried on `SftpEntry`/`LocalEntry` from listing/scan time (no blocking stat at dialog-open); one shared `EntryContextMenu` for both panels (Open / Open with / View / Edit / Copy to target with up-front feasibility reasons incl. the same-folder block / Refresh / New folder / Permissions / Rename / Delete) wired through the dual-panel transfer matrix; shared app-launch helpers extracted to `util/app_launcher.dart`, **Bulk action panel (0.1.30)** — SELECT mode on the hosts dashboard (per-card checkboxes, filter-aware Select all, Esc to exit) with Connect all (skips already-connected hosts, confirms before opening more than 5 tabs), Run command in parallel (free text or snippet; bounded concurrency, 30 s per-host timeout, per-host failure isolation; per-host exit code / duration / expandable stdout+stderr; a Diff tab groups identical outputs against a promotable baseline and side-by-side compares any two hosts), and Push files to one remote path on every host (destination created if missing, per-host byte progress, cancel); closing a dialog mid-run confirms, cancels queued hosts and lets in-flight operations record their real result, **Dashboard grid & list view + sorting (0.1.30)** — card grid ↔ compact one-line list toggle and a sort dropdown (name / creation date / hostname, asc/desc), both persisted across restarts; default order Name A–Z, **Agent forwarding observability (0.1.30)** — pre-connect agent status line in the host panel (system agent identities / app-Keychain fallback / nothing to serve), live per-session key icon on the session tab (grey ready / green active / orange Keychain fallback / red refused), and a notification-bell item with tap-to-jump when the server refuses forwarding, **Connection Chain editor (0.1.30)** — Termius-style visual chain replacing the jump-host dropdown in the host panel (bastion card → arrow → destination, searchable Add-a-Host picker, agent-forwarding key icon, Clear; `HostChainEditor`, single hop), **Jump host on auto-connect paths (0.1.30)** — `ensureClient` (SFTP / exec / port forwarding) now resolves `Host.jumpHostId` via `defaultJumpHostLookup`, so hosts behind a bastion tunnel correctly outside interactive sessions; plus `tool/jump_probe.dart`, a layer-by-layer jump diagnostic CLI, **Session template / per-host preset (0.1.32)** — per-host working dir + env vars delivered invisibly via the shell-integration handshake (tty canonical mode lifted during the payload read so large templates don't truncate at the 4096-byte line cap), startup snippet typed visibly after the DONE sentinel (skipped under tmux and on handshake abort — a re-attach must not replay it), and per-host terminal theme / font / size / TERM / tmux overrides falling back to globals (out-of-range synced values rejected at render); SESSION TEMPLATE section in the host panel with env-key validation incl. duplicate-name detection, **Internal audit log (0.1.32)** — local SQLite (`sqlite3`, WAL, `/audit.db`) trail of connect/disconnect/exec/input events with per-caller source tagging (`bulk` / `devops` / `plugin:` / `input-bar` / broadcast targets each get their own row; internal polls like network stats and OS detection are exempt), secret redaction before insert (key=value incl. quoted multi-word values, Bearer tokens, sshpass/mysql-family `-p`, redis-cli `-a`, URL userinfo — applied to meta error strings too), Audit Log screen with type/time/search filters, keyset pagination, CSV/JSON export (macOS sandbox entitlement upgraded to user-selected read-write), retention pruning at launch (default 90 days, configurable in Settings → Audit) and fail-soft writes that can never break an SSH operation, **In-app SSH key generation (0.1.32)** — generate Ed25519 (pure Dart in the dartssh2 fork: `OpenSSHEd25519KeyPair.generate()` + passphrase-encrypting `toPem` via bcrypt-pbkdf + aes256-ctr, interop-verified against real `ssh-keygen -y`) / RSA-4096 / ECDSA-P256 (via system `ssh-keygen`; options gated by a binary probe) from the Keychain screen; keys land in `Documents/YourSSH/keys` mode 600 with the passphrase in secure storage; copy public key + ssh-copy-id-style **deploy-to-host dialog** (idempotent `grep -qxF`, EXISTS/ADDED markers) — includes the deploy stretch goal, **Local shell picker (0.1.32)** — choose which shell local terminal tabs run: auto-detected per platform (Windows: PowerShell, cmd, PowerShell 7, Git Bash, one profile per WSL distro; macOS/Linux: `$SHELL` + `/etc/shells`) plus user-added custom executables with arguments; default in Settings → Terminal, per-session choice in the new-tab (+) menu, Restart shell reuses the session's shell, an uninstalled default falls back to the platform default with a terminal warning (`ShellProfile`, `shell_detection.dart`), **Multi-hop jump chain (0.1.32)** — bastion → bastion → target for layered networks: `Host.jumpHostIds` ordered chain (legacy `jumpHostId` migrates and is dual-written to JSON for cross-version sync), `SshService` dials hop-over-hop via `forwardLocal` with chain-prefix-keyed client cache, deepest-first refcounted teardown and a cycle guard, per-hop host-key verification; `HostChainEditor` appends/removes hops (persistent Add a Host, per-hop ×, Clear); `ensureClient` and test-connection resolve the full chain, **Recording redaction (0.1.32)** — secrets masked before being written to `.cast`: line-buffered redaction in `RecordingService` reusing `AuditRedactor` unchanged (split at the last newline, start-once 500 ms flush timer, stop flushes the tail; per-line coalescing also strips keystroke timing), global Settings toggle AND per-host opt-out (both default on; pure `effectiveRecordingRedaction` policy sampled once at record start with a fresh `HostProvider` lookup), **In-app RDP client (0.1.33, #44)** — Windows / xrdp desktops as first-class tabs: IronRDP Rust engine via flutter_rust_bridge v2 (`packages/yourssh_rdp`), NLA/TLS/auto security, direct or SSH-tunneled through a saved jump host (full connection chain reused), TOFU certificate pinning enforced in Rust pre-CredSSP (changed cert aborts before credentials are sent), server-negotiated resolution handling, bidirectional clipboard, full keyboard/mouse incl. Ctrl+Alt+Del, OS-level fullscreen with mstsc-style hover pill + auto-exit safety, graceful server-initiated disconnect (MCS ultimatum → clean message, not a raw protocol error), protocol-aware dashboard actions, RDP badge, tab parity (rename/color/pin/restore/audit/notification bell), **Keyword highlighting (develop)** — user-defined regex rules with per-rule color picker (defaults: Error/Warning/Fail in red/yellow/cyan); rendered in the xterm fork at paint time (`paintKeywordForeground`, `KeywordHighlightRule`) wired through `TerminalView → RenderTerminal`; toggle + rule list + add/edit dialog in Settings → Terminal and the terminal config side panel, **Server monitor panel (develop)** — per-host live dashboard (CPU/mem/disk/uptime/listening ports/firewall) via draggable bottom sheet; accessed from host card hover button and context menu; `SystemStatsService` polls every 5 s via compound SSH exec with sentinel markers (`__CPU1__`/`__CPU2__`/`__MEM__`/`__DISK__`/`__UPTIME__`/`__PORTS__`), `FirewallStatusService` polls every 30 s (ufw/iptables-save/nftables auto-detected); pure parser models `SystemSnapshot` + `FirewallStatus`; requires an active SSH session for the host. --- @@ -26,7 +26,6 @@ Already shipped (not repeated in roadmap): multi-tab terminal, split view, broad ### Terminal UX & protocol support - **Richer autocomplete** — _cwd-aware path completion shipped (0.1.18)._ Remaining: option / argument-aware completion sourced from snippets + built-ins, and suggesting a matching key/identity on password prompts. -- **Keyword highlighting** — user-defined regex rules (defaults: Error/Warning/Fail) tint matching terminal output; wire through the existing HookBus `terminal.output` hook or the xterm fork. - **OSC 52 clipboard** — let remote apps (tmux, vim) write to the local clipboard through the escape sequence; xterm fork addition, opt-in per host for safety. - **Grid split layouts** — beyond the current 2-pane horizontal/vertical: 2×2+, drag-to-rearrange panes, per-pane resize persistence. Extends `TerminalLayoutProvider`. - **Terminal emulation & charset per host** — selectable `TERM` type (xterm-256color / linux / vt100) and charset (UTF-8 / legacy codepages) for network gear and legacy servers. @@ -77,7 +76,7 @@ Already shipped (not repeated in roadmap): multi-tab terminal, split view, broad 1. **Kubernetes panel completion** (P0 #1) — context switcher + `logs -f` + 1-click port-forward; the container browser shipped in 0.1.12, finishing the K8s story is the clearest next DevOps milestone. 2. **Connection proxy support** (P1 Security) — HTTP CONNECT / SOCKS5 per host for restricted networks; the natural complement to the just-shipped multi-hop jump chain. -3. **Keyword highlighting** (P1 Terminal UX) — regex rules tint Error/Warning/Fail in terminal output; the HookBus `terminal.output` hook already exists, making this a small high-visibility UX win. +3. **OSC 52 clipboard** (P1 Terminal UX) — let remote apps (tmux, vim) write to the local clipboard through the escape sequence; xterm fork addition, opt-in per host for safety; small, focused, high-visibility with no server-side requirements. --- From 7aefd75358be8e14d11c1abb6cb0f08401ed99ff Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Mon, 8 Jun 2026 20:49:04 +0700 Subject: [PATCH 22/59] =?UTF-8?q?docs(spec):=20discover=20local=20devices?= =?UTF-8?q?=20=E2=80=94=20mDNS=20+=20TCP=20scan=20design?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...026-06-08-discover-local-devices-design.md | 176 ++++++++++++++++++ 1 file changed, 176 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-08-discover-local-devices-design.md diff --git a/docs/superpowers/specs/2026-06-08-discover-local-devices-design.md b/docs/superpowers/specs/2026-06-08-discover-local-devices-design.md new file mode 100644 index 00000000..ec0e65ba --- /dev/null +++ b/docs/superpowers/specs/2026-06-08-discover-local-devices-design.md @@ -0,0 +1,176 @@ +# Discover Local Devices — Design + +**Date:** 2026-06-08 +**Status:** Approved + +## Overview + +Scan the local network for SSH/RDP-capable devices and allow users to add them as hosts with OS auto-detection. Combines mDNS/Bonjour discovery (real-time, no scan needed) with parallel TCP port scanning across the detected subnet. + +Entry points: +- **Hosts Dashboard** — "Discover" button next to the "+" Add Host button +- **Host Detail Panel** — "Scan network to pick a device" link under the IP field (add-new-host mode only) + +--- + +## Architecture + +### Model: `DiscoveredHost` + +```dart +// app/lib/models/discovered_host.dart + +enum DiscoverySource { mdns, tcpScan, both } + +class DiscoveredHost { + final String ip; + final String? hostname; // from mDNS service name or reverse DNS + final List openPorts; // e.g. [22, 3389] + final DiscoverySource source; + final String? mdnsServiceType; // "_ssh._tcp", "_rdp._tcp", etc. +} +``` + +### Model: `SubnetInfo` + +```dart +class SubnetInfo { + final String interfaceName; // "en0", "eth0" + final String displayName; // "Wi-Fi", "Ethernet" + final String address; // "192.168.1.5" + final String subnet; // "192.168.1.0/24" +} +``` + +Subnet is derived from the interface address assuming `/24` (covers virtually all home/office LANs). The user can override the subnet string before scanning. + +### Service: `NetworkDiscoveryService` + +**File:** `app/lib/services/network_discovery_service.dart` + +``` +NetworkDiscoveryService + ├── getLocalSubnets() → Future> + │ reuses NetworkInterface.list logic from P2PSyncService, adds subnet derivation + │ + ├── scan(SubnetInfo subnet, {List ports, Duration timeout}) → Stream + │ merges two sub-streams, deduplicates by IP + │ ├── _runMdnsScan() → uses multicast_dns package, watches _ssh._tcp / _sftp-ssh._tcp / _rdp._tcp + │ └── _runTcpScan() → Socket.connect per (ip, port), 50 concurrent max, 500ms timeout + │ + └── cancel() → stops both sub-streams, closes all pending sockets +``` + +**Default ports scanned:** `[22, 2222, 3389]` + +**mDNS service types watched:** `_ssh._tcp`, `_sftp-ssh._tcp`, `_rdp._tcp` + +**Deduplication:** results are keyed by IP. When the same IP arrives from both mDNS and TCP scan, they are merged into a single `DiscoveredHost` with `source: both` and ports union-merged. + +**Concurrency:** TCP scan uses a `Semaphore`-style counter (max 50 in-flight `Socket.connect` at a time) so 254 addresses are worked through in ~3 seconds at 500ms timeout. + +**Dependency:** add `multicast_dns: ^0.3.2` to `app/pubspec.yaml`. + +--- + +## UI + +### `NetworkDiscoverySheet` + +**File:** `app/lib/widgets/network_discovery_sheet.dart` + +Draggable bottom sheet (same pattern as `ServerMonitorSheet`). Two modes: + +- **Browse mode** (opened from dashboard "Discover" button) — user can Add or Connect each result +- **Selection mode** (opened from Host Detail Panel) — tap a result closes the sheet and pre-fills the panel + +``` +┌─────────────────────────────────────────────┐ +│ Discover Devices [×] │ +│ │ +│ Interface: Wi-Fi 192.168.1.0/24 [Edit] │ +│ ████████████████░░░░ Scanning… 127/254 │ +│ │ +│ ● raspberrypi.local 192.168.1.42 SSH │ +│ ● MacBook-Pro.local 192.168.1.10 SSH │ +│ ● DESKTOP-WIN11 192.168.1.55 RDP │ +│ ○ 192.168.1.88 (no hostname) SSH │ +│ [Add ▾] [Connect] │ +│ │ +│ mDNS: 3 found · TCP scan: 1 found │ +└─────────────────────────────────────────────┘ +``` + +**Behavior:** +- mDNS results appear immediately as the sheet opens (real-time stream) +- TCP scan progress bar is shown while scan is running; hidden on completion or cancel +- Interface selector shows all `SubnetInfo` from `getLocalSubnets()`; user can also type a custom subnet (validated inline — must be valid CIDR; Start Scan disabled while invalid) +- Each row shows: hostname (or IP if no hostname), IP badge, port badge (SSH / SSH:2222 / RDP) +- **[Add]** opens `HostDetailPanel` pre-filled: `host=ip`, `port=openPorts.first`, `protocol` derived from port (3389→RDP, else SSH), display name from hostname if present +- **[Connect]** same as Add but calls `SessionProvider.connectHost` immediately after saving +- In **selection mode** rows are tappable; tap closes the sheet and calls `onSelected(DiscoveredHost)` + +### Hosts Dashboard + +`app/lib/widgets/hosts_dashboard.dart`: add an icon button `Icons.wifi_find` (label "Discover") beside the existing "+" add-host button in the app bar / actions row. Tapping opens `NetworkDiscoverySheet` in browse mode. + +### Host Detail Panel + +`app/lib/widgets/host_detail_panel.dart`: when creating a new host (no existing host id), add a text button `🔍 Scan network to pick a device` below the host/IP text field. Tapping opens `NetworkDiscoverySheet` in selection mode. On selection, the panel's fields are updated: `_hostController.text = ip`, `_portController.text = port`, display name set to hostname, protocol toggled if RDP. + +--- + +## OS Auto-Detection + +No changes to the detection flow. After a discovered host is added and the user connects, `SessionProvider` calls `SshService.detectOs` as it already does for every new SSH session. The `detectedOs` field on `Host` is null until first connect, same as manually-added hosts. + +--- + +## Error Handling + +| Situation | Behavior | +|---|---| +| No active network interfaces | Empty state in sheet: "No active network interfaces found" | +| mDNS socket fails (firewall / permission) | TCP scan continues; mDNS counter shows warning icon | +| Individual TCP connect timeout/error | Silent skip — does not interrupt the stream | +| Invalid custom subnet entered | Inline validation error; "Start Scan" button disabled | +| Sheet closed while scan in progress | `cancel()` called immediately; all pending sockets aborted | +| `multicast_dns` package unavailable at runtime | mDNS sub-stream skipped; TCP-only scan proceeds | + +--- + +## Testing + +### Unit tests — `NetworkDiscoveryService` + +Inject a `SocketConnector` function type `(String ip, int port, Duration timeout) → Future` so tests never touch real network: + +- Deduplication: same IP from mDNS + TCP scan → one result with `source: both` +- Concurrency cap: verify no more than 50 concurrent in-flight connects +- Stream completion: stream closes after TCP scan finishes (mDNS stays open until `cancel()`) +- Cancel: stream emits no more items after `cancel()` called mid-scan + +### Unit tests — mDNS path + +Wrap `MDnsClient` behind a `MdnsScanner` interface; inject a fake that emits pre-canned `ResourceRecord` entries. + +### Widget tests — `NetworkDiscoverySheet` + +- Progress bar visible while scanning, hidden after completion +- Rows render correctly given a mock `Stream` +- "Add" button triggers `HostDetailPanel` pre-fill +- Selection mode: tap closes sheet and calls `onSelected` + +--- + +## File Checklist + +| File | Change | +|---|---| +| `app/pubspec.yaml` | add `multicast_dns: ^0.3.2` | +| `app/lib/models/discovered_host.dart` | new | +| `app/lib/services/network_discovery_service.dart` | new | +| `app/lib/widgets/network_discovery_sheet.dart` | new | +| `app/lib/widgets/hosts_dashboard.dart` | add Discover button | +| `app/lib/widgets/host_detail_panel.dart` | add "Scan network" link in add-new-host mode | +| `app/test/services/network_discovery_service_test.dart` | new | From 0d98afe7f268a94c9f5620cdf89f94923bc946b8 Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Mon, 8 Jun 2026 20:49:23 +0700 Subject: [PATCH 23/59] fix(keyword-highlighting): address code-review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - local_terminal_pane + recording_player_widget: pass keywordRules so local shells and recording playback actually show highlights - split_terminal_view + main_screen: thread onNavigateToSettings so the 'Manage rules in Settings →' link in the config panel is reachable - terminal_view: remove redundant context.select (context.watch already subscribes; select was a no-op and produced a new list identity each build) - render.dart: build a string-index → cell-column map before matching so highlights land on the correct pixel column when wide (CJK) chars are present - keyword_highlight_rule: guard toXtermRule against both-null fg+bg (avoids a no-op rule scanning every visible line); treat missing 'id' in fromJson as null so constructor auto-generates a UUID instead of throwing TypeError - keyword_highlight_settings: re-read provider state after await showDialog to avoid stale-snapshot overwrites; re-check rule cap before add() to close the sync-race window that could push count past kMaxKeywordHighlightRules --- app/lib/models/keyword_highlight_rule.dart | 3 +- app/lib/screens/main_screen.dart | 7 +++- .../widgets/keyword_highlight_settings.dart | 9 +++-- app/lib/widgets/local_terminal_pane.dart | 8 ++++ app/lib/widgets/recording_player_widget.dart | 8 ++++ app/lib/widgets/split_terminal_view.dart | 4 +- app/lib/widgets/terminal_view.dart | 16 ++++---- packages/xterm/lib/src/ui/render.dart | 39 ++++++++++++++++--- 8 files changed, 72 insertions(+), 22 deletions(-) diff --git a/app/lib/models/keyword_highlight_rule.dart b/app/lib/models/keyword_highlight_rule.dart index 0f4bc1ea..bf932c88 100644 --- a/app/lib/models/keyword_highlight_rule.dart +++ b/app/lib/models/keyword_highlight_rule.dart @@ -24,6 +24,7 @@ class AppKeywordHighlightRule { }) : id = id ?? const Uuid().v4(); xterm.KeywordHighlightRule? toXtermRule() { + if (foreground == null && background == null) return null; try { final rawPattern = isRegex ? pattern : RegExp.escape(pattern); final compiled = RegExp(rawPattern, caseSensitive: caseSensitive); @@ -50,7 +51,7 @@ class AppKeywordHighlightRule { factory AppKeywordHighlightRule.fromJson(Map json) { return AppKeywordHighlightRule( - id: json['id'] as String, + id: json['id'] as String?, label: json['label'] as String, pattern: json['pattern'] as String, isRegex: json['isRegex'] as bool? ?? false, diff --git a/app/lib/screens/main_screen.dart b/app/lib/screens/main_screen.dart index 42307d15..4e175d89 100644 --- a/app/lib/screens/main_screen.dart +++ b/app/lib/screens/main_screen.dart @@ -794,7 +794,12 @@ class _MainScreenState extends State with WidgetsBindingObserver { Expanded( child: Stack( children: [ - const SplitTerminalView(), + SplitTerminalView( + onNavigateToSettings: () => setState(() { + _nav = NavSection.settings; + _viewingTerminal = false; + }), + ), Positioned( top: 8, right: showAiChat ? 348 : 8, diff --git a/app/lib/widgets/keyword_highlight_settings.dart b/app/lib/widgets/keyword_highlight_settings.dart index 206f5100..087f1cd9 100644 --- a/app/lib/widgets/keyword_highlight_settings.dart +++ b/app/lib/widgets/keyword_highlight_settings.dart @@ -118,13 +118,14 @@ class KeywordHighlightSection extends StatelessWidget { builder: (_) => _KeywordRuleDialog(initial: rule), ); if (result == null || !context.mounted) return; - final updated = List.from(settings.keywordHighlightRules); + final current = context.read(); + final updated = List.from(current.keywordHighlightRules); if (index != null) { - updated[index] = result; + if (index < updated.length) updated[index] = result; } else { - updated.add(result); + if (updated.length < kMaxKeywordHighlightRules) updated.add(result); } - context.read().save(keywordHighlightRules: updated); + current.save(keywordHighlightRules: updated); } } diff --git a/app/lib/widgets/local_terminal_pane.dart b/app/lib/widgets/local_terminal_pane.dart index 0bcd1f84..d8cb34d1 100644 --- a/app/lib/widgets/local_terminal_pane.dart +++ b/app/lib/widgets/local_terminal_pane.dart @@ -47,6 +47,13 @@ class _LocalTerminalPaneState extends State { Widget _terminal(BuildContext context) { final settings = context.watch(); + final keywordRules = settings.keywordHighlightingEnabled + ? settings.keywordHighlightRules + .where((r) => r.enabled) + .map((r) => r.toXtermRule()) + .whereType() + .toList() + : const []; return Stack( children: [ TerminalView( @@ -54,6 +61,7 @@ class _LocalTerminalPaneState extends State { session.terminal, controller: _controller, autofocus: true, + keywordRules: keywordRules, textStyle: TerminalStyle( fontSize: settings.fontSize, fontFamily: settings.terminalFont, diff --git a/app/lib/widgets/recording_player_widget.dart b/app/lib/widgets/recording_player_widget.dart index 66fce4be..ee07e165 100644 --- a/app/lib/widgets/recording_player_widget.dart +++ b/app/lib/widgets/recording_player_widget.dart @@ -134,6 +134,13 @@ class _RecordingPlayerWidgetState extends State { final settings = context.watch(); final theme = terminalThemeByName(settings.terminalTheme); final progress = _events.isEmpty ? 0.0 : _currentIndex / _events.length; + final keywordRules = settings.keywordHighlightingEnabled + ? settings.keywordHighlightRules + .where((r) => r.enabled) + .map((r) => r.toXtermRule()) + .whereType() + .toList() + : const []; return Column( children: [ @@ -145,6 +152,7 @@ class _RecordingPlayerWidgetState extends State { padding: EdgeInsets.zero, autofocus: false, readOnly: true, + keywordRules: keywordRules, ), ), Container( diff --git a/app/lib/widgets/split_terminal_view.dart b/app/lib/widgets/split_terminal_view.dart index 0ebae6a0..49f9c7ad 100644 --- a/app/lib/widgets/split_terminal_view.dart +++ b/app/lib/widgets/split_terminal_view.dart @@ -20,7 +20,8 @@ import 'terminal_config_panel.dart'; import 'terminal_snippets_panel.dart'; class SplitTerminalView extends StatelessWidget { - const SplitTerminalView({super.key}); + final VoidCallback? onNavigateToSettings; + const SplitTerminalView({super.key, this.onNavigateToSettings}); void _sendCommand(TerminalSession session, String command) { session.terminal.textInput(command); @@ -96,6 +97,7 @@ class SplitTerminalView extends StatelessWidget { TerminalConfigPanel( onClose: () => layout.toggleSidePanel(SidePanel.terminalConfig), + onOpenSettings: onNavigateToSettings, ), ], ), diff --git a/app/lib/widgets/terminal_view.dart b/app/lib/widgets/terminal_view.dart index 507112f3..35e364f4 100644 --- a/app/lib/widgets/terminal_view.dart +++ b/app/lib/widgets/terminal_view.dart @@ -430,15 +430,13 @@ class _TerminalWidgetState extends State<_TerminalWidget> { @override Widget build(BuildContext context) { final settings = context.watch(); - final keywordRules = context.select>( - (s) => s.keywordHighlightingEnabled - ? s.keywordHighlightRules - .where((r) => r.enabled) - .map((r) => r.toXtermRule()) - .whereType() - .toList() - : const [], - ); + final keywordRules = settings.keywordHighlightingEnabled + ? settings.keywordHighlightRules + .where((r) => r.enabled) + .map((r) => r.toXtermRule()) + .whereType() + .toList() + : const []; final appearance = _appearance(watch: true); final theme = terminalThemeByName(appearance.themeName); final showGutter = settings.shellIntegrationEnabled && diff --git a/packages/xterm/lib/src/ui/render.dart b/packages/xterm/lib/src/ui/render.dart index b2339d9c..136ee8cd 100644 --- a/packages/xterm/lib/src/ui/render.dart +++ b/packages/xterm/lib/src/ui/render.dart @@ -560,6 +560,22 @@ class RenderTerminal extends RenderBox with RelayoutWhenSystemFontsChangeMixin { _painter.paintHighlight(canvas, startOffset, end - start, color); } + // Returns a list mapping string-character index → cell column. + // getText() emits one code-unit per code-point, skipping continuation cells + // of double-width characters, so string index ≠ cell column when wide chars + // are present. + List _buildStrToCell(BufferLine line) { + final result = []; + for (var col = 0; col < line.length; col++) { + final cp = line.getCodePoint(col); + if (cp != 0) { + result.add(col); + if (line.getWidth(col) == 2) col++; + } + } + return result; + } + void _paintKeywordHighlights(Canvas canvas, int firstLine, int lastLine) { if (_keywordRules.isEmpty) return; final lines = _terminal.buffer.lines; @@ -567,18 +583,29 @@ class RenderTerminal extends RenderBox with RelayoutWhenSystemFontsChangeMixin { for (var i = firstLine; i <= lastLine; i++) { if (i >= lines.length) break; - final lineText = lines[i].getText(); + final line = lines[i]; + final lineText = line.getText(); final lineY = (i * charHeight + _lineOffset).truncateToDouble(); + final strToCell = _buildStrToCell(line); for (final rule in _keywordRules) { for (final m in rule.pattern.allMatches(lineText)) { if (m.start == m.end) continue; + final startCell = + m.start < strToCell.length ? strToCell[m.start] : m.start; + final lastCharCell = m.end > 0 && m.end - 1 < strToCell.length + ? strToCell[m.end - 1] + : m.end - 1; + final endCell = + lastCharCell + (line.getWidth(lastCharCell) == 2 ? 2 : 1); + final cellCount = endCell - startCell; + if (rule.background != null) { _painter.paintHighlight( canvas, - Offset(m.start * _painter.cellSize.width, lineY), - m.end - m.start, + Offset(startCell * _painter.cellSize.width, lineY), + cellCount, rule.background!, ); } @@ -587,9 +614,9 @@ class RenderTerminal extends RenderBox with RelayoutWhenSystemFontsChangeMixin { _painter.paintKeywordForeground( canvas, Offset(0, lineY), - lines[i], - m.start, - m.end, + line, + startCell, + endCell, rule.foreground!, ); } From 20dcd74686c8f3611bf1c968ddc410853a5a7493 Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Mon, 8 Jun 2026 20:55:45 +0700 Subject: [PATCH 24/59] docs(plan): discover local devices implementation plan --- .../2026-06-08-discover-local-devices.md | 1372 +++++++++++++++++ 1 file changed, 1372 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-08-discover-local-devices.md diff --git a/docs/superpowers/plans/2026-06-08-discover-local-devices.md b/docs/superpowers/plans/2026-06-08-discover-local-devices.md new file mode 100644 index 00000000..3a271fd8 --- /dev/null +++ b/docs/superpowers/plans/2026-06-08-discover-local-devices.md @@ -0,0 +1,1372 @@ +# Discover Local Devices Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add LAN device discovery (mDNS + TCP port scan) so users can find SSH/RDP hosts on their network and add them with a single click. + +**Architecture:** `NetworkDiscoveryService` streams `DiscoveredHost` results from two parallel sub-scans (mDNS via `multicast_dns`, TCP via `Socket.connect` with 50-concurrent semaphore); results are deduplicated by IP in a `Map`. A `NetworkDiscoverySheet` bottom sheet shows results in real-time and pre-fills `HostDetailPanel` on Add/Connect. Entry points: Hosts Dashboard toolbar button and a "Scan network" link in the Add Host panel. + +**Tech Stack:** Dart `dart:io` (Socket), `multicast_dns: ^0.3.2`, Flutter `showModalBottomSheet`, existing `HostDetailPanel` / `SessionProvider` patterns. + +--- + +## File Map + +| File | Action | +|---|---| +| `app/pubspec.yaml` | add `multicast_dns: ^0.3.2` | +| `app/lib/models/discovered_host.dart` | new — `DiscoveredHost`, `SubnetInfo`, `DiscoverySource` | +| `app/lib/services/network_discovery_service.dart` | new — service with TCP scan + mDNS | +| `app/test/services/network_discovery_service_test.dart` | new — unit tests | +| `app/lib/widgets/network_discovery_sheet.dart` | new — bottom sheet UI | +| `app/lib/widgets/hosts_dashboard.dart` | add Discover button to `_TopBar` | +| `app/lib/widgets/host_detail_panel.dart` | add "Scan network" link in add-new-host mode | + +--- + +## Task 1: Add dependency + models + +**Files:** +- Modify: `app/pubspec.yaml` +- Create: `app/lib/models/discovered_host.dart` + +- [ ] **Step 1: Add multicast_dns to pubspec** + +In `app/pubspec.yaml`, after the `# Network info (LAN share)` block add: + +```yaml + # mDNS/Bonjour device discovery + multicast_dns: ^0.3.2 +``` + +- [ ] **Step 2: Create `app/lib/models/discovered_host.dart`** + +```dart +import 'dart:io'; + +enum DiscoverySource { mdns, tcpScan, both } + +class DiscoveredHost { + final String ip; + final String? hostname; + final List openPorts; + final DiscoverySource source; + final String? mdnsServiceType; + + const DiscoveredHost({ + required this.ip, + this.hostname, + required this.openPorts, + required this.source, + this.mdnsServiceType, + }); + + DiscoveredHost merge(DiscoveredHost other) { + final ports = {...openPorts, ...other.openPorts}.toList()..sort(); + return DiscoveredHost( + ip: ip, + hostname: hostname ?? other.hostname, + openPorts: ports, + source: DiscoverySource.both, + mdnsServiceType: mdnsServiceType ?? other.mdnsServiceType, + ); + } + + String get portLabel { + if (openPorts.contains(3389)) return 'RDP'; + if (openPorts.contains(22)) return 'SSH'; + if (openPorts.contains(2222)) return 'SSH:2222'; + return openPorts.first.toString(); + } + + bool get isRdp => openPorts.contains(3389) && !openPorts.contains(22); +} + +class SubnetInfo { + final String interfaceName; + final String displayName; + final String address; + final String subnet; + + const SubnetInfo({ + required this.interfaceName, + required this.displayName, + required this.address, + required this.subnet, + }); + + static String subnetFromAddress(String address) { + final parts = address.split('.'); + return '${parts[0]}.${parts[1]}.${parts[2]}.0/24'; + } + + static List hostsInSubnet(String subnet) { + final base = subnet.split('/').first; + final parts = base.split('.'); + final prefix = '${parts[0]}.${parts[1]}.${parts[2]}'; + return List.generate(254, (i) => '$prefix.${i + 1}'); + } + + /// Returns null when [subnet] is not a valid x.x.x.x/y string. + static String? validateSubnet(String subnet) { + final parts = subnet.split('/'); + if (parts.length != 2) return 'Expected format: 192.168.1.0/24'; + final octets = parts[0].split('.'); + if (octets.length != 4) return 'Expected 4 octets'; + for (final o in octets) { + final n = int.tryParse(o); + if (n == null || n < 0 || n > 255) return 'Invalid octet: $o'; + } + final prefix = int.tryParse(parts[1]); + if (prefix == null || prefix < 1 || prefix > 32) return 'Prefix must be 1–32'; + return null; + } + + @override + String toString() => '$displayName ($address) — $subnet'; +} +``` + +- [ ] **Step 3: Run `flutter pub get`** + +```bash +cd app && flutter pub get +``` + +Expected: resolves `multicast_dns` without errors. + +- [ ] **Step 4: Commit** + +```bash +git add app/pubspec.yaml app/pubspec.lock app/lib/models/discovered_host.dart +git commit -m "feat(discover): add DiscoveredHost model + multicast_dns dep" +``` + +--- + +## Task 2: NetworkDiscoveryService — TCP scan + +**Files:** +- Create: `app/lib/services/network_discovery_service.dart` + +- [ ] **Step 1: Create the service file** + +```dart +import 'dart:async'; +import 'dart:io'; +import 'package:flutter/foundation.dart'; +import 'package:multicast_dns/multicast_dns.dart'; +import '../models/discovered_host.dart'; + +typedef SocketConnector = Future Function( + String ip, int port, Duration timeout); +typedef MdnsClientFactory = MDnsClient Function(); + +const _kDefaultPorts = [22, 2222, 3389]; +const _kMdnsServiceTypes = ['_ssh._tcp', '_sftp-ssh._tcp', '_rdp._tcp']; +const _kConcurrency = 50; + +Future _defaultConnector(String ip, int port, Duration timeout) async { + try { + final s = await Socket.connect(ip, port, timeout: timeout); + s.destroy(); + return true; + } catch (_) { + return false; + } +} + +class _Semaphore { + final int max; + int _count = 0; + final _queue = >[]; + + _Semaphore(this.max); + + Future acquire() async { + if (_count < max) { + _count++; + return; + } + final c = Completer(); + _queue.add(c); + await c.future; + _count++; + } + + void release() { + _count--; + if (_queue.isNotEmpty) _queue.removeAt(0).complete(); + } +} + +class NetworkDiscoveryService { + final SocketConnector _connector; + final MdnsClientFactory _mdnsFactory; + + bool _cancelled = false; + + NetworkDiscoveryService({ + @visibleForTesting SocketConnector? connector, + @visibleForTesting MdnsClientFactory? mdnsFactory, + }) : _connector = connector ?? _defaultConnector, + _mdnsFactory = mdnsFactory ?? MDnsClient.new; + + Future> getLocalSubnets() async { + final interfaces = + await NetworkInterface.list(type: InternetAddressType.IPv4); + return interfaces + .expand((i) => i.addresses.map((a) => SubnetInfo( + interfaceName: i.name, + displayName: _displayName(i.name), + address: a.address, + subnet: SubnetInfo.subnetFromAddress(a.address), + ))) + .where((s) => !s.address.startsWith('127.')) + .toList(); + } + + /// Streams discovered hosts in real-time. Deduplicates by IP. + /// [onProgress] is called after each TCP probe with (scanned, total). + Stream scan( + SubnetInfo subnet, { + List ports = _kDefaultPorts, + Duration timeout = const Duration(milliseconds: 500), + void Function(int scanned, int total)? onProgress, + }) { + _cancelled = false; + final controller = StreamController(); + final seen = {}; + + void emit(DiscoveredHost h) { + if (_cancelled) return; + final existing = seen[h.ip]; + if (existing == null) { + seen[h.ip] = h; + controller.add(h); + } else { + final merged = existing.merge(h); + seen[h.ip] = merged; + controller.add(merged); + } + } + + Future run() async { + final tcpFuture = _runTcpScan( + SubnetInfo.hostsInSubnet(subnet.subnet), + ports, + timeout, + emit, + onProgress, + ); + final mdnsFuture = _runMdnsScan(emit); + await Future.wait([tcpFuture, mdnsFuture]); + if (!controller.isClosed) controller.close(); + } + + run().catchError((_) { + if (!controller.isClosed) controller.close(); + }); + + return controller.stream; + } + + void cancel() => _cancelled = true; + + Future _runTcpScan( + List ips, + List ports, + Duration timeout, + void Function(DiscoveredHost) emit, + void Function(int, int)? onProgress, + ) async { + final sem = _Semaphore(_kConcurrency); + final total = ips.length; + var scanned = 0; + + Future probe(String ip) async { + await sem.acquire(); + try { + if (_cancelled) return; + final openPorts = []; + for (final port in ports) { + if (_cancelled) break; + if (await _connector(ip, port, timeout)) openPorts.add(port); + } + if (openPorts.isNotEmpty && !_cancelled) { + emit(DiscoveredHost( + ip: ip, openPorts: openPorts, source: DiscoverySource.tcpScan)); + } + } finally { + sem.release(); + scanned++; + onProgress?.call(scanned, total); + } + } + + await Future.wait(ips.map(probe)); + } + + Future _runMdnsScan(void Function(DiscoveredHost) emit) async { + MDnsClient? client; + try { + client = _mdnsFactory(); + await client.start(); + for (final serviceType in _kMdnsServiceTypes) { + if (_cancelled) break; + try { + await for (final PtrResourceRecord ptr + in client + .lookup( + ResourceRecordQuery.serverPointer('$serviceType.local')) + .timeout(const Duration(seconds: 5))) { + if (_cancelled) break; + await for (final SrvResourceRecord srv + in client.lookup( + ResourceRecordQuery.service(ptr.domainName))) { + if (_cancelled) break; + String? ip; + String? hostname = srv.target.replaceAll('.local', ''); + try { + await for (final IPAddressResourceRecord addr + in client.lookup( + ResourceRecordQuery.addressIPv4(srv.target))) { + ip = addr.address.address; + break; + } + } catch (_) {} + if (ip == null) { + try { + final result = await InternetAddress.lookup(srv.target); + if (result.isNotEmpty) ip = result.first.address; + } catch (_) {} + } + if (ip != null && !_cancelled) { + emit(DiscoveredHost( + ip: ip, + hostname: hostname, + openPorts: [srv.port], + source: DiscoverySource.mdns, + mdnsServiceType: serviceType, + )); + } + } + } + } catch (_) { + // timeout or error on this service type — continue with next + } + } + } catch (_) { + // mDNS socket failed — TCP scan continues unaffected + } finally { + client?.stop(); + } + } + + static String _displayName(String name) { + final n = name.toLowerCase(); + if (n == 'en0') return 'Wi-Fi'; + if (n.startsWith('en')) return 'Ethernet'; + if (n.startsWith('utun') || n.startsWith('tun') || n.startsWith('tap')) { + return 'VPN'; + } + if (n.startsWith('wlan') || n.startsWith('wlp')) return 'Wi-Fi'; + if (n.startsWith('eth')) return 'Ethernet'; + return name; + } +} +``` + +- [ ] **Step 2: Analyze pass** + +```bash +cd app && flutter analyze lib/services/network_discovery_service.dart +``` + +Expected: no errors. + +- [ ] **Step 3: Commit** + +```bash +git add app/lib/services/network_discovery_service.dart +git commit -m "feat(discover): NetworkDiscoveryService — TCP scan + mDNS" +``` + +--- + +## Task 3: Unit tests for NetworkDiscoveryService + +**Files:** +- Create: `app/test/services/network_discovery_service_test.dart` + +- [ ] **Step 1: Create test file** + +```dart +import 'dart:async'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:multicast_dns/multicast_dns.dart'; +import 'package:yourssh/models/discovered_host.dart'; +import 'package:yourssh/services/network_discovery_service.dart'; + +// Fake connector that returns true for predetermined (ip, port) pairs. +SocketConnector _fakeConnector(Map> openPorts) { + return (ip, port, _) async => openPorts[ip]?.contains(port) ?? false; +} + +// Connector that counts concurrent in-flight calls. +SocketConnector _countingConnector({ + required List maxConcurrencyLog, + SocketConnector? delegate, +}) { + var current = 0; + return (ip, port, timeout) async { + current++; + maxConcurrencyLog.add(current); + await Future.delayed(const Duration(milliseconds: 5)); + current--; + return delegate?.call(ip, port, timeout) ?? false; + }; +} + +// Fake MDnsClient that yields no records — allows mDNS path to complete. +class _FakeMdnsClient implements MDnsClient { + @override + Future start({ + InternetAddress? listenAddress, + NetworkInterfacesFactory? interfacesFactory, + }) async {} + + @override + void stop() {} + + @override + Stream lookup( + ResourceRecordQuery query, { + Duration timeout = const Duration(seconds: 5), + }) => + const Stream.empty(); +} + +MDnsClient _fakeMdnsFactory() => _FakeMdnsClient(); + +void main() { + group('SubnetInfo.subnetFromAddress', () { + test('derives /24 subnet from IP', () { + expect(SubnetInfo.subnetFromAddress('192.168.1.42'), '192.168.1.0/24'); + }); + }); + + group('SubnetInfo.hostsInSubnet', () { + test('produces 254 hosts for /24', () { + final hosts = SubnetInfo.hostsInSubnet('192.168.1.0/24'); + expect(hosts.length, 254); + expect(hosts.first, '192.168.1.1'); + expect(hosts.last, '192.168.1.254'); + }); + }); + + group('SubnetInfo.validateSubnet', () { + test('returns null for valid subnet', () { + expect(SubnetInfo.validateSubnet('192.168.1.0/24'), isNull); + }); + + test('returns error for missing prefix', () { + expect(SubnetInfo.validateSubnet('192.168.1.0'), isNotNull); + }); + + test('returns error for invalid octet', () { + expect(SubnetInfo.validateSubnet('192.168.999.0/24'), isNotNull); + }); + }); + + group('NetworkDiscoveryService TCP scan', () { + test('emits hosts with open ports only', () async { + final svc = NetworkDiscoveryService( + connector: _fakeConnector({'192.168.1.10': [22], '192.168.1.20': [3389]}), + mdnsFactory: _fakeMdnsFactory, + ); + final subnet = SubnetInfo( + interfaceName: 'en0', + displayName: 'Wi-Fi', + address: '192.168.1.5', + subnet: '192.168.1.0/24', + ); + final results = await svc + .scan(subnet, ports: [22, 3389], timeout: const Duration(milliseconds: 1)) + .toList(); + + expect(results.map((r) => r.ip), containsAll(['192.168.1.10', '192.168.1.20'])); + final ssh = results.firstWhere((r) => r.ip == '192.168.1.10'); + expect(ssh.openPorts, [22]); + expect(ssh.source, DiscoverySource.tcpScan); + }); + + test('deduplicates by IP — merge ports from multiple probe hits', () async { + // Same IP responds on both port 22 and 2222 + final svc = NetworkDiscoveryService( + connector: _fakeConnector({'10.0.0.1': [22, 2222]}), + mdnsFactory: _fakeMdnsFactory, + ); + final subnet = SubnetInfo( + interfaceName: 'eth0', + displayName: 'Ethernet', + address: '10.0.0.5', + subnet: '10.0.0.0/24', + ); + final results = await svc + .scan(subnet, ports: [22, 2222], timeout: const Duration(milliseconds: 1)) + .toList(); + + // Multiple emissions for same IP may arrive — last one should have both ports + final last = results.lastWhere((r) => r.ip == '10.0.0.1'); + expect(last.openPorts, containsAll([22, 2222])); + }); + + test('respects cancel — stops emitting after cancel()', () async { + var emitted = 0; + final svc = NetworkDiscoveryService( + connector: (ip, port, _) async { + await Future.delayed(const Duration(milliseconds: 10)); + return true; + }, + mdnsFactory: _fakeMdnsFactory, + ); + final subnet = SubnetInfo( + interfaceName: 'en0', + displayName: 'Wi-Fi', + address: '192.168.1.5', + subnet: '192.168.1.0/24', + ); + final sub = svc + .scan(subnet, ports: [22], timeout: const Duration(milliseconds: 10)) + .listen((_) => emitted++); + await Future.delayed(const Duration(milliseconds: 5)); + svc.cancel(); + await Future.delayed(const Duration(milliseconds: 50)); + await sub.cancel(); + // Some may have been emitted before cancel; key assertion: scan does not crash + expect(emitted, greaterThanOrEqualTo(0)); + }); + + test('concurrency does not exceed 50', () async { + final log = []; + final svc = NetworkDiscoveryService( + connector: _countingConnector(maxConcurrencyLog: log), + mdnsFactory: _fakeMdnsFactory, + ); + final subnet = SubnetInfo( + interfaceName: 'en0', + displayName: 'Wi-Fi', + address: '10.0.0.1', + subnet: '10.0.0.0/24', + ); + await svc.scan(subnet, ports: [22], timeout: const Duration(milliseconds: 1)).drain(); + expect(log.reduce((a, b) => a > b ? a : b), lessThanOrEqualTo(50)); + }); + + test('onProgress reaches 100% at end', () async { + int lastScanned = 0, lastTotal = 0; + final svc = NetworkDiscoveryService( + connector: _fakeConnector({}), + mdnsFactory: _fakeMdnsFactory, + ); + final subnet = SubnetInfo( + interfaceName: 'en0', + displayName: 'Wi-Fi', + address: '192.168.1.1', + subnet: '192.168.1.0/24', + ); + await svc.scan( + subnet, + ports: [22], + timeout: const Duration(milliseconds: 1), + onProgress: (s, t) { + lastScanned = s; + lastTotal = t; + }, + ).drain(); + expect(lastScanned, lastTotal); + expect(lastTotal, 254); + }); + }); + + group('DiscoveredHost', () { + test('merge combines ports and prefers existing hostname', () { + final a = DiscoveredHost( + ip: '10.0.0.1', hostname: 'myhost', openPorts: [22], source: DiscoverySource.mdns); + final b = DiscoveredHost( + ip: '10.0.0.1', openPorts: [3389], source: DiscoverySource.tcpScan); + final m = a.merge(b); + expect(m.hostname, 'myhost'); + expect(m.openPorts, [22, 3389]); + expect(m.source, DiscoverySource.both); + }); + + test('isRdp true when only 3389 open', () { + final h = DiscoveredHost(ip: '1.2.3.4', openPorts: [3389], source: DiscoverySource.tcpScan); + expect(h.isRdp, true); + }); + + test('isRdp false when 22 also open', () { + final h = DiscoveredHost(ip: '1.2.3.4', openPorts: [22, 3389], source: DiscoverySource.tcpScan); + expect(h.isRdp, false); + }); + }); +} +``` + +- [ ] **Step 2: Run tests** + +```bash +cd app && flutter test test/services/network_discovery_service_test.dart -v +``` + +Expected: all tests pass. + +- [ ] **Step 3: Commit** + +```bash +git add app/test/services/network_discovery_service_test.dart +git commit -m "test(discover): NetworkDiscoveryService unit tests" +``` + +--- + +## Task 4: NetworkDiscoverySheet widget + +**Files:** +- Create: `app/lib/widgets/network_discovery_sheet.dart` + +- [ ] **Step 1: Create the widget** + +```dart +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../models/discovered_host.dart'; +import '../models/host.dart'; +import '../providers/host_provider.dart'; +import '../providers/session_provider.dart'; +import '../services/network_discovery_service.dart'; +import '../theme/app_theme.dart'; +import 'host_detail_panel.dart'; + +class NetworkDiscoverySheet extends StatefulWidget { + /// When true, tapping a row calls [onSelected] and closes the sheet. + final bool selectionMode; + final void Function(DiscoveredHost)? onSelected; + + const NetworkDiscoverySheet({ + super.key, + this.selectionMode = false, + this.onSelected, + }); + + static void show( + BuildContext context, { + bool selectionMode = false, + void Function(DiscoveredHost)? onSelected, + }) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (_) => NetworkDiscoverySheet( + selectionMode: selectionMode, + onSelected: onSelected, + ), + ); + } + + @override + State createState() => NetworkDiscoverySheetState(); +} + +class NetworkDiscoverySheetState extends State { + final _svc = NetworkDiscoveryService(); + final _results = {}; + + List _subnets = []; + SubnetInfo? _selected; + String _customSubnet = ''; + bool _editingSubnet = false; + String? _subnetError; + + bool _scanning = false; + int _scanned = 0; + int _total = 0; + int _mdnsCount = 0; + int _tcpCount = 0; + + StreamSubscription? _sub; + + @override + void initState() { + super.initState(); + _loadSubnets(); + } + + @override + void dispose() { + _sub?.cancel(); + _svc.cancel(); + super.dispose(); + } + + Future _loadSubnets() async { + final subnets = await _svc.getLocalSubnets(); + if (!mounted) return; + setState(() { + _subnets = subnets; + _selected = subnets.isNotEmpty ? subnets.first : null; + if (_selected != null) _customSubnet = _selected!.subnet; + }); + if (_selected != null) _startScan(); + } + + void _startScan() { + if (_selected == null) return; + _sub?.cancel(); + final subnet = _editingSubnet + ? SubnetInfo( + interfaceName: _selected!.interfaceName, + displayName: _selected!.displayName, + address: _selected!.address, + subnet: _customSubnet, + ) + : _selected!; + + setState(() { + _scanning = true; + _scanned = 0; + _total = 0; + _mdnsCount = 0; + _tcpCount = 0; + _results.clear(); + }); + + _sub = _svc + .scan(subnet, onProgress: (s, t) { + if (mounted) setState(() { _scanned = s; _total = t; }); + }) + .listen( + (h) { + if (!mounted) return; + setState(() { + final isUpdate = _results.containsKey(h.ip); + _results[h.ip] = h; + if (!isUpdate) { + if (h.source == DiscoverySource.mdns) _mdnsCount++; + else _tcpCount++; + } else if (h.source == DiscoverySource.both) { + // Promoted from single source — update counter display + } + }); + }, + onDone: () { if (mounted) setState(() => _scanning = false); }, + onError: (_) { if (mounted) setState(() => _scanning = false); }, + ); + } + + void _stopScan() { + _sub?.cancel(); + _svc.cancel(); + setState(() => _scanning = false); + } + + void _onAdd(BuildContext context, DiscoveredHost h) { + Navigator.of(context).pop(); + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (_) => HostDetailPanel( + existing: null, + initialHost: h.ip, + initialPort: h.isRdp ? 3389 : (h.openPorts.contains(22) ? 22 : h.openPorts.first), + initialLabel: h.hostname, + initialProtocol: h.isRdp ? HostProtocol.rdp : HostProtocol.ssh, + ), + ); + } + + void _onConnect(BuildContext context, DiscoveredHost h) { + final hostProvider = context.read(); + final sessionProvider = context.read(); + Navigator.of(context).pop(); + + final host = Host( + id: const Uuid().v4(), + label: h.hostname ?? h.ip, + host: h.ip, + port: h.isRdp ? 3389 : (h.openPorts.contains(22) ? 22 : h.openPorts.first), + username: '', + protocol: h.isRdp ? HostProtocol.rdp : HostProtocol.ssh, + createdAt: DateTime.now(), + ); + await hostProvider.addHost(host); + if (host.protocol == HostProtocol.rdp) { + sessionProvider.connectRdp(context, host); + } else { + sessionProvider.connectHost(context, host); + } + } + + @override + Widget build(BuildContext context) { + return DraggableScrollableSheet( + initialChildSize: 0.7, + minChildSize: 0.4, + maxChildSize: 0.95, + builder: (context, scroll) => Container( + decoration: const BoxDecoration( + color: AppColors.background, + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + child: Column( + children: [ + _buildHandle(), + _buildHeader(context), + _buildSubnetBar(), + if (_scanning) _buildProgress(), + _buildCounterRow(), + const Divider(color: AppColors.border, height: 1), + Expanded(child: _buildResultList(context, scroll)), + ], + ), + ), + ); + } + + Widget _buildHandle() => Container( + width: 40, + height: 4, + margin: const EdgeInsets.symmetric(vertical: 8), + decoration: BoxDecoration( + color: AppColors.border, + borderRadius: BorderRadius.circular(2), + ), + ); + + Widget _buildHeader(BuildContext context) => Padding( + padding: const EdgeInsets.fromLTRB(20, 4, 12, 8), + child: Row( + children: [ + const Icon(Icons.wifi_find, color: AppColors.accent, size: 18), + const SizedBox(width: 8), + const Text('Discover Devices', + style: TextStyle( + color: AppColors.textPrimary, + fontSize: 15, + fontWeight: FontWeight.w600)), + const Spacer(), + IconButton( + icon: const Icon(Icons.close, size: 18), + color: AppColors.textSecondary, + onPressed: () => Navigator.of(context).pop(), + ), + ], + ), + ); + + Widget _buildSubnetBar() { + if (_subnets.isEmpty) { + return const Padding( + padding: EdgeInsets.all(16), + child: Text('No active network interfaces found.', + style: TextStyle(color: AppColors.textSecondary)), + ); + } + return Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 8), + child: Row( + children: [ + if (_subnets.length > 1) + DropdownButton( + value: _selected, + dropdownColor: AppColors.card, + style: const TextStyle(color: AppColors.textPrimary, fontSize: 13), + underline: const SizedBox(), + items: _subnets + .map((s) => DropdownMenuItem( + value: s, + child: Text(s.displayName), + )) + .toList(), + onChanged: (s) { + setState(() { + _selected = s; + _customSubnet = s?.subnet ?? ''; + _editingSubnet = false; + _subnetError = null; + }); + }, + ) + else + Text(_selected?.displayName ?? '', + style: const TextStyle( + color: AppColors.textSecondary, fontSize: 13)), + const SizedBox(width: 8), + Expanded( + child: TextField( + controller: TextEditingController(text: _customSubnet), + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 13, + fontFamily: 'monospace'), + decoration: InputDecoration( + isDense: true, + border: InputBorder.none, + errorText: _subnetError, + errorStyle: const TextStyle(fontSize: 10), + ), + onChanged: (v) { + setState(() { + _customSubnet = v; + _editingSubnet = true; + _subnetError = SubnetInfo.validateSubnet(v); + }); + }, + ), + ), + const SizedBox(width: 8), + TextButton( + onPressed: _subnetError != null ? null : () { + if (_scanning) _stopScan(); + _startScan(); + }, + child: Text(_scanning ? 'Restart' : 'Scan', + style: const TextStyle(fontSize: 12)), + ), + ], + ), + ); + } + + Widget _buildProgress() { + final progress = _total > 0 ? _scanned / _total : 0.0; + return Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + LinearProgressIndicator( + value: progress, + backgroundColor: AppColors.border, + color: AppColors.accent, + ), + const SizedBox(height: 4), + Text( + _total > 0 ? 'Scanning… $_scanned/$_total' : 'Starting scan…', + style: const TextStyle(color: AppColors.textTertiary, fontSize: 11), + ), + ], + ), + ); + } + + Widget _buildCounterRow() => Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 8), + child: Text( + 'mDNS: $_mdnsCount found · TCP scan: $_tcpCount found', + style: const TextStyle(color: AppColors.textTertiary, fontSize: 11), + ), + ); + + Widget _buildResultList(BuildContext context, ScrollController scroll) { + final items = _results.values.toList() + ..sort((a, b) => a.ip.compareTo(b.ip)); + if (items.isEmpty) { + return const Center( + child: Text('No devices found yet…', + style: TextStyle(color: AppColors.textTertiary)), + ); + } + return ListView.builder( + controller: scroll, + itemCount: items.length, + itemBuilder: (ctx, i) => _DiscoveredRow( + host: items[i], + selectionMode: widget.selectionMode, + onSelect: () { + widget.onSelected?.call(items[i]); + Navigator.of(context).pop(); + }, + onAdd: () => _onAdd(context, items[i]), + onConnect: () => _onConnect(context, items[i]), + ), + ); + } +} + +class _DiscoveredRow extends StatelessWidget { + final DiscoveredHost host; + final bool selectionMode; + final VoidCallback onSelect; + final VoidCallback onAdd; + final VoidCallback onConnect; + + const _DiscoveredRow({ + required this.host, + required this.selectionMode, + required this.onSelect, + required this.onAdd, + required this.onConnect, + }); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: selectionMode ? onSelect : null, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), + child: Row( + children: [ + Icon( + host.isRdp ? Icons.desktop_windows : Icons.computer, + size: 16, + color: AppColors.textSecondary, + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + host.hostname ?? host.ip, + style: const TextStyle( + color: AppColors.textPrimary, fontSize: 13), + ), + if (host.hostname != null) + Text(host.ip, + style: const TextStyle( + color: AppColors.textTertiary, fontSize: 11)), + ], + ), + ), + _Badge(host.portLabel), + if (!selectionMode) ...[ + const SizedBox(width: 8), + _SmallBtn('Add', onAdd), + const SizedBox(width: 4), + _SmallBtn('Connect', onConnect, primary: true), + ], + ], + ), + ), + ); + } +} + +class _Badge extends StatelessWidget { + final String label; + const _Badge(this.label); + + @override + Widget build(BuildContext context) => Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(4), + border: Border.all(color: AppColors.border), + ), + child: Text(label, + style: const TextStyle( + color: AppColors.textSecondary, fontSize: 11)), + ); +} + +class _SmallBtn extends StatelessWidget { + final String label; + final VoidCallback onTap; + final bool primary; + + const _SmallBtn(this.label, this.onTap, {this.primary = false}); + + @override + Widget build(BuildContext context) => InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(4), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: primary ? AppColors.accent : AppColors.card, + borderRadius: BorderRadius.circular(4), + border: Border.all( + color: primary ? AppColors.accent : AppColors.border), + ), + child: Text( + label, + style: TextStyle( + color: primary ? Colors.white : AppColors.textPrimary, + fontSize: 11, + fontWeight: FontWeight.w500, + ), + ), + ), + ); +} +``` + +> **Note:** `_onConnect` references `Uuid` — add `import 'package:uuid/uuid.dart';` at the top of the file. Also `HostProtocol` is from `../models/host.dart`. + +- [ ] **Step 2: Fix imports in `network_discovery_sheet.dart`** + +Add to top of file: +```dart +import 'package:uuid/uuid.dart'; +``` + +- [ ] **Step 3: Analyze** + +```bash +cd app && flutter analyze lib/widgets/network_discovery_sheet.dart +``` + +Expected: no errors (some warnings about `HostDetailPanel` constructor — will be fixed in Task 5). + +- [ ] **Step 4: Commit** + +```bash +git add app/lib/widgets/network_discovery_sheet.dart +git commit -m "feat(discover): NetworkDiscoverySheet — bottom sheet UI" +``` + +--- + +## Task 5: Add initialHost/Port/Label/Protocol params to HostDetailPanel + +The `_onAdd` in the sheet needs to pre-fill the panel. `HostDetailPanel` currently takes an `existing` host — we need optional initial-value params for the new-host case. + +**Files:** +- Modify: `app/lib/widgets/host_detail_panel.dart` + +- [ ] **Step 1: Read the constructor** + +Open `app/lib/widgets/host_detail_panel.dart` and find the `HostDetailPanel` class definition and its `initState`. It should look like: + +```dart +class HostDetailPanel extends StatefulWidget { + final Host? existing; + // ... other params +``` + +And in `initState`: +```dart +_hostCtrl = TextEditingController(text: h?.host ?? ''); +_labelCtrl = TextEditingController(text: h?.label ?? ''); +_portCtrl = TextEditingController(text: (h?.port ?? _protocol.defaultPort).toString()); +``` + +- [ ] **Step 2: Add optional initial-value parameters to `HostDetailPanel`** + +In the `HostDetailPanel` class, add four optional params: + +```dart +final String? initialHost; +final int? initialPort; +final String? initialLabel; +final HostProtocol? initialProtocol; +``` + +In the constructor, add them as optional named params. + +In `initState`, change the controller initialization to fall back to the initial values when `existing` is null: + +```dart +_protocol = h?.protocol ?? widget.initialProtocol ?? HostProtocol.ssh; +// ... +_hostCtrl = TextEditingController(text: h?.host ?? widget.initialHost ?? ''); +_labelCtrl = TextEditingController(text: h?.label ?? widget.initialLabel ?? ''); +_portCtrl = TextEditingController( + text: (h?.port ?? widget.initialPort ?? _protocol.defaultPort).toString()); +``` + +- [ ] **Step 3: Verify analyze** + +```bash +cd app && flutter analyze lib/widgets/host_detail_panel.dart +``` + +Expected: no errors. + +- [ ] **Step 4: Commit** + +```bash +git add app/lib/widgets/host_detail_panel.dart +git commit -m "feat(discover): add initialHost/Port/Label/Protocol to HostDetailPanel" +``` + +--- + +## Task 6: Wire Discover button in Hosts Dashboard + +**Files:** +- Modify: `app/lib/widgets/hosts_dashboard.dart` + +- [ ] **Step 1: Add `onDiscover` callback to `HostsDashboard` widget** + +In the `HostsDashboard` class, add: +```dart +final VoidCallback? onDiscover; +``` + +In the constructor, add `this.onDiscover`. In the `_TopBar` instantiation (line ~236), pass it: +```dart +onDiscover: widget.onDiscover, +``` + +- [ ] **Step 2: Add `onDiscover` to `_TopBar`** + +In `_TopBar`, add: +```dart +final VoidCallback? onDiscover; +``` + +In the constructor add `this.onDiscover`. In the `build` Row, add after the `_ViewToggle` widget and before the `_OutlinedBtn(SELECT)`: + +```dart +_OutlinedBtn( + icon: Icons.wifi_find, + label: 'DISCOVER', + onTap: onDiscover ?? () {}, +), +const SizedBox(width: 8), +``` + +- [ ] **Step 3: Wire it in `main_screen.dart`** + +Find where `HostsDashboard` is instantiated in `app/lib/screens/main_screen.dart`. Add the `onDiscover` callback: + +```dart +HostsDashboard( + // ... existing params + onDiscover: () => NetworkDiscoverySheet.show(context), +) +``` + +Add the import at the top of `main_screen.dart`: +```dart +import '../widgets/network_discovery_sheet.dart'; +``` + +- [ ] **Step 4: Analyze** + +```bash +cd app && flutter analyze lib/widgets/hosts_dashboard.dart lib/screens/main_screen.dart +``` + +Expected: no errors. + +- [ ] **Step 5: Commit** + +```bash +git add app/lib/widgets/hosts_dashboard.dart app/lib/screens/main_screen.dart +git commit -m "feat(discover): Discover button on Hosts Dashboard toolbar" +``` + +--- + +## Task 7: "Scan network" link in Host Detail Panel + +**Files:** +- Modify: `app/lib/widgets/host_detail_panel.dart` + +- [ ] **Step 1: Add import for NetworkDiscoverySheet** + +At the top of `host_detail_panel.dart`, add: +```dart +import 'network_discovery_sheet.dart'; +``` + +- [ ] **Step 2: Add the "Scan network" link below the address card** + +Find the section in `build` that renders the `_AddressField` card (around line 342–345): + +```dart +_Card(children: [ + _AddressField(controller: _hostCtrl), +]), +``` + +Replace with: + +```dart +_Card(children: [ + _AddressField(controller: _hostCtrl), +]), +if (_isNew) ...[ + const SizedBox(height: 4), + Align( + alignment: Alignment.centerRight, + child: TextButton.icon( + icon: const Icon(Icons.wifi_find, size: 13), + label: const Text('Scan network to pick a device', + style: TextStyle(fontSize: 12)), + style: TextButton.styleFrom( + foregroundColor: AppColors.textSecondary, + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4)), + onPressed: () => NetworkDiscoverySheet.show( + context, + selectionMode: true, + onSelected: (h) { + setState(() { + _hostCtrl.text = h.ip; + _portCtrl.text = + (h.isRdp ? 3389 : (h.openPorts.contains(22) ? 22 : h.openPorts.first)) + .toString(); + if (h.hostname != null && _labelCtrl.text.isEmpty) { + _labelCtrl.text = h.hostname!; + } + if (h.isRdp && _protocol != HostProtocol.rdp) _onProtocolChanged(HostProtocol.rdp); + }); + }, + ), + ), + ), +], +``` + +> `_switchProtocol` is the existing method in `HostDetailPanelState` that handles the protocol toggle. + +- [ ] **Step 3: Analyze** + +```bash +cd app && flutter analyze lib/widgets/host_detail_panel.dart +``` + +Expected: no errors. + +- [ ] **Step 4: Commit** + +```bash +git add app/lib/widgets/host_detail_panel.dart +git commit -m "feat(discover): Scan network link in Add Host panel" +``` + +--- + +## Task 8: Final integration check + +- [ ] **Step 1: Full analyze** + +```bash +cd app && flutter analyze +``` + +Expected: no errors. + +- [ ] **Step 2: Run all tests** + +```bash +cd app && flutter test +``` + +Expected: all pass including the new `network_discovery_service_test.dart`. + +- [ ] **Step 3: Final commit if any loose changes** + +```bash +git add -p +git commit -m "feat(discover): discover local devices — mDNS + TCP scan" +``` From 3e79c262639df2043a0afaf9c62ab721178afc33 Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Mon, 8 Jun 2026 20:56:35 +0700 Subject: [PATCH 25/59] feat(discover): add DiscoveredHost model + multicast_dns dep --- app/lib/models/discovered_host.dart | 82 +++++++++++++++++++++++++++++ app/pubspec.lock | 8 +++ app/pubspec.yaml | 3 ++ 3 files changed, 93 insertions(+) create mode 100644 app/lib/models/discovered_host.dart diff --git a/app/lib/models/discovered_host.dart b/app/lib/models/discovered_host.dart new file mode 100644 index 00000000..0334b34d --- /dev/null +++ b/app/lib/models/discovered_host.dart @@ -0,0 +1,82 @@ +enum DiscoverySource { mdns, tcpScan, both } + +class DiscoveredHost { + final String ip; + final String? hostname; + final List openPorts; + final DiscoverySource source; + final String? mdnsServiceType; + + const DiscoveredHost({ + required this.ip, + this.hostname, + required this.openPorts, + required this.source, + this.mdnsServiceType, + }); + + DiscoveredHost merge(DiscoveredHost other) { + final ports = {...openPorts, ...other.openPorts}.toList()..sort(); + return DiscoveredHost( + ip: ip, + hostname: hostname ?? other.hostname, + openPorts: ports, + source: DiscoverySource.both, + mdnsServiceType: mdnsServiceType ?? other.mdnsServiceType, + ); + } + + String get portLabel { + if (openPorts.isEmpty) return '?'; + if (openPorts.contains(3389)) return 'RDP'; + if (openPorts.contains(22)) return 'SSH'; + if (openPorts.contains(2222)) return 'SSH:2222'; + return openPorts.first.toString(); + } + + bool get isRdp => openPorts.contains(3389) && !openPorts.contains(22); +} + +class SubnetInfo { + final String interfaceName; + final String displayName; + final String address; + final String subnet; + + const SubnetInfo({ + required this.interfaceName, + required this.displayName, + required this.address, + required this.subnet, + }); + + static String subnetFromAddress(String address) { + final parts = address.split('.'); + return '${parts[0]}.${parts[1]}.${parts[2]}.0/24'; + } + + static List hostsInSubnet(String subnet) { + final base = subnet.split('/').first; + final parts = base.split('.'); + final prefix = '${parts[0]}.${parts[1]}.${parts[2]}'; + return List.generate(254, (i) => '$prefix.${i + 1}'); + } + + /// Returns null when [subnet] is a valid x.x.x.x/y string. + static String? validateSubnet(String subnet) { + final parts = subnet.split('/'); + if (parts.length != 2) return 'Expected format: 192.168.1.0/24'; + final octets = parts[0].split('.'); + if (octets.length != 4) return 'Expected 4 octets'; + for (final o in octets) { + final n = int.tryParse(o); + if (n == null || n < 0 || n > 255) return 'Invalid octet: $o'; + } + final prefix = int.tryParse(parts[1]); + if (prefix == null || prefix < 1 || prefix > 32) return 'Prefix must be 1–32'; + return null; + } + + @override + String toString() => '$displayName ($address) — $subnet'; +} diff --git a/app/pubspec.lock b/app/pubspec.lock index ffbc365d..6daca5d4 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -669,6 +669,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" + multicast_dns: + dependency: "direct main" + description: + name: multicast_dns + sha256: de72ada5c3db6fdd6ad4ae99452fe05fb403c4bb37c67ceb255ddd37d2b5b1eb + url: "https://pub.dev" + source: hosted + version: "0.3.3" nested: dependency: transitive description: diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 89a17860..c76ffa5a 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -85,6 +85,9 @@ dependencies: # Network info (LAN share) network_info_plus: ^6.0.0 + # mDNS/Bonjour device discovery + multicast_dns: ^0.3.2 + # HTTP server (LAN share) shelf: ^1.4.1 From 821dbc3b68612178e293ff6cf8ba5d1230db6096 Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Mon, 8 Jun 2026 20:57:19 +0700 Subject: [PATCH 26/59] =?UTF-8?q?feat(discover):=20NetworkDiscoveryService?= =?UTF-8?q?=20=E2=80=94=20TCP=20scan=20+=20mDNS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../services/network_discovery_service.dart | 223 ++++++++++++++++++ 1 file changed, 223 insertions(+) create mode 100644 app/lib/services/network_discovery_service.dart diff --git a/app/lib/services/network_discovery_service.dart b/app/lib/services/network_discovery_service.dart new file mode 100644 index 00000000..8e30f4f8 --- /dev/null +++ b/app/lib/services/network_discovery_service.dart @@ -0,0 +1,223 @@ +import 'dart:async'; +import 'dart:io'; +import 'package:flutter/foundation.dart'; +import 'package:multicast_dns/multicast_dns.dart'; +import '../models/discovered_host.dart'; + +typedef SocketConnector = Future Function( + String ip, int port, Duration timeout); +typedef MdnsClientFactory = MDnsClient Function(); + +const _kDefaultPorts = [22, 2222, 3389]; +const _kMdnsServiceTypes = ['_ssh._tcp', '_sftp-ssh._tcp', '_rdp._tcp']; +const _kConcurrency = 50; + +Future _defaultConnector(String ip, int port, Duration timeout) async { + try { + final s = await Socket.connect(ip, port, timeout: timeout); + s.destroy(); + return true; + } catch (_) { + return false; + } +} + +class _Semaphore { + final int max; + int _count = 0; + final _queue = >[]; + + _Semaphore(this.max); + + Future acquire() async { + if (_count < max) { + _count++; + return; + } + final c = Completer(); + _queue.add(c); + await c.future; + _count++; + } + + void release() { + _count--; + if (_queue.isNotEmpty) _queue.removeAt(0).complete(); + } +} + +class NetworkDiscoveryService { + final SocketConnector _connector; + final MdnsClientFactory _mdnsFactory; + + bool _cancelled = false; + + NetworkDiscoveryService({ + @visibleForTesting SocketConnector? connector, + @visibleForTesting MdnsClientFactory? mdnsFactory, + }) : _connector = connector ?? _defaultConnector, + _mdnsFactory = mdnsFactory ?? MDnsClient.new; + + Future> getLocalSubnets() async { + final interfaces = + await NetworkInterface.list(type: InternetAddressType.IPv4); + return interfaces + .expand((i) => i.addresses.map((a) => SubnetInfo( + interfaceName: i.name, + displayName: _displayName(i.name), + address: a.address, + subnet: SubnetInfo.subnetFromAddress(a.address), + ))) + .where((s) => !s.address.startsWith('127.')) + .toList(); + } + + /// Streams discovered hosts in real-time. Deduplicates by IP. + /// [onProgress] fires after each IP is probed with (scanned, total). + Stream scan( + SubnetInfo subnet, { + List ports = _kDefaultPorts, + Duration timeout = const Duration(milliseconds: 500), + void Function(int scanned, int total)? onProgress, + }) { + _cancelled = false; + final controller = StreamController(); + final seen = {}; + + void emit(DiscoveredHost h) { + if (_cancelled || controller.isClosed) return; + final existing = seen[h.ip]; + if (existing == null) { + seen[h.ip] = h; + controller.add(h); + } else { + final merged = existing.merge(h); + seen[h.ip] = merged; + controller.add(merged); + } + } + + Future run() async { + await Future.wait([ + _runTcpScan( + SubnetInfo.hostsInSubnet(subnet.subnet), + ports, + timeout, + emit, + onProgress, + ), + _runMdnsScan(emit), + ]); + if (!controller.isClosed) controller.close(); + } + + run().catchError((_) { + if (!controller.isClosed) controller.close(); + }); + + return controller.stream; + } + + void cancel() => _cancelled = true; + + Future _runTcpScan( + List ips, + List ports, + Duration timeout, + void Function(DiscoveredHost) emit, + void Function(int, int)? onProgress, + ) async { + final sem = _Semaphore(_kConcurrency); + final total = ips.length; + var scanned = 0; + + Future probe(String ip) async { + await sem.acquire(); + try { + if (_cancelled) return; + final openPorts = []; + for (final port in ports) { + if (_cancelled) break; + if (await _connector(ip, port, timeout)) openPorts.add(port); + } + if (openPorts.isNotEmpty && !_cancelled) { + emit(DiscoveredHost( + ip: ip, openPorts: openPorts, source: DiscoverySource.tcpScan)); + } + } finally { + sem.release(); + scanned++; + onProgress?.call(scanned, total); + } + } + + await Future.wait(ips.map(probe)); + } + + Future _runMdnsScan(void Function(DiscoveredHost) emit) async { + MDnsClient? client; + try { + client = _mdnsFactory(); + await client.start(); + for (final serviceType in _kMdnsServiceTypes) { + if (_cancelled) break; + try { + await for (final PtrResourceRecord ptr in client + .lookup( + ResourceRecordQuery.serverPointer('$serviceType.local')) + .timeout(const Duration(seconds: 5))) { + if (_cancelled) break; + await for (final SrvResourceRecord srv in client + .lookup( + ResourceRecordQuery.service(ptr.domainName))) { + if (_cancelled) break; + String? ip; + final hostname = srv.target.replaceAll(RegExp(r'\.local\.?$'), ''); + try { + await for (final IPAddressResourceRecord addr in client + .lookup( + ResourceRecordQuery.addressIPv4(srv.target))) { + ip = addr.address.address; + break; + } + } catch (_) {} + if (ip == null) { + try { + final result = await InternetAddress.lookup(srv.target); + if (result.isNotEmpty) ip = result.first.address; + } catch (_) {} + } + if (ip != null && !_cancelled) { + emit(DiscoveredHost( + ip: ip, + hostname: hostname.isEmpty ? null : hostname, + openPorts: [srv.port], + source: DiscoverySource.mdns, + mdnsServiceType: serviceType, + )); + } + } + } + } catch (_) { + // timeout or socket error on this service type — continue with next + } + } + } catch (_) { + // mDNS socket bind failed — TCP scan continues unaffected + } finally { + client?.stop(); + } + } + + static String _displayName(String name) { + final n = name.toLowerCase(); + if (n == 'en0') return 'Wi-Fi'; + if (n.startsWith('wlan') || n.startsWith('wlp')) return 'Wi-Fi'; + if (n.startsWith('en')) return 'Ethernet'; + if (n.startsWith('eth')) return 'Ethernet'; + if (n.startsWith('utun') || n.startsWith('tun') || n.startsWith('tap')) { + return 'VPN'; + } + return name; + } +} From 87be2364dd96a5a6783e01f73feecbb8841b91fa Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Mon, 8 Jun 2026 20:59:06 +0700 Subject: [PATCH 27/59] test(discover): NetworkDiscoveryService unit tests --- .../network_discovery_service_test.dart | 233 ++++++++++++++++++ 1 file changed, 233 insertions(+) create mode 100644 app/test/services/network_discovery_service_test.dart diff --git a/app/test/services/network_discovery_service_test.dart b/app/test/services/network_discovery_service_test.dart new file mode 100644 index 00000000..e13d7b88 --- /dev/null +++ b/app/test/services/network_discovery_service_test.dart @@ -0,0 +1,233 @@ +import 'dart:async'; +import 'dart:io'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:multicast_dns/multicast_dns.dart'; +import 'package:yourssh/models/discovered_host.dart'; +import 'package:yourssh/services/network_discovery_service.dart'; + +SocketConnector _fakeConnector(Map> openPorts) { + return (ip, port, _) async => openPorts[ip]?.contains(port) ?? false; +} + +SocketConnector _countingConnector({required List log}) { + var current = 0; + return (ip, port, timeout) async { + current++; + log.add(current); + await Future.delayed(const Duration(milliseconds: 5)); + current--; + return false; + }; +} + +// Fake MDnsClient: yields no records, completes immediately. +class _FakeMdnsClient implements MDnsClient { + @override + Future start({ + InternetAddress? listenAddress, + NetworkInterfacesFactory? interfacesFactory, + int mDnsPort = 5353, + InternetAddress? mDnsAddress, + Function? onError, + }) async {} + + @override + void stop() {} + + @override + Stream lookup( + ResourceRecordQuery query, { + Duration timeout = const Duration(seconds: 5), + }) => + const Stream.empty(); + + @override + Future> allInterfacesFactory( + InternetAddressType type) => + NetworkInterface.list(type: type); +} + +MDnsClient _fakeMdns() => _FakeMdnsClient(); + +SubnetInfo _subnet(String address, String subnet) => SubnetInfo( + interfaceName: 'en0', + displayName: 'Wi-Fi', + address: address, + subnet: subnet, + ); + +void main() { + group('SubnetInfo.subnetFromAddress', () { + test('derives /24 subnet', () { + expect(SubnetInfo.subnetFromAddress('192.168.1.42'), '192.168.1.0/24'); + }); + }); + + group('SubnetInfo.hostsInSubnet', () { + test('produces 254 hosts for /24', () { + final hosts = SubnetInfo.hostsInSubnet('192.168.1.0/24'); + expect(hosts.length, 254); + expect(hosts.first, '192.168.1.1'); + expect(hosts.last, '192.168.1.254'); + }); + }); + + group('SubnetInfo.validateSubnet', () { + test('null for valid subnet', () => + expect(SubnetInfo.validateSubnet('192.168.1.0/24'), isNull)); + + test('error for missing prefix', () => + expect(SubnetInfo.validateSubnet('192.168.1.0'), isNotNull)); + + test('error for invalid octet', () => + expect(SubnetInfo.validateSubnet('192.168.999.0/24'), isNotNull)); + + test('error for bad prefix', () => + expect(SubnetInfo.validateSubnet('10.0.0.0/33'), isNotNull)); + }); + + group('NetworkDiscoveryService TCP scan', () { + test('emits hosts with open ports only', () async { + final svc = NetworkDiscoveryService( + connector: _fakeConnector({ + '192.168.1.10': [22], + '192.168.1.20': [3389], + }), + mdnsFactory: _fakeMdns, + ); + final results = await svc + .scan( + _subnet('192.168.1.5', '192.168.1.0/24'), + ports: [22, 3389], + timeout: const Duration(milliseconds: 1), + ) + .toList(); + + final ips = results.map((r) => r.ip).toSet(); + expect(ips, containsAll(['192.168.1.10', '192.168.1.20'])); + final ssh = results.firstWhere((r) => r.ip == '192.168.1.10'); + expect(ssh.openPorts, [22]); + expect(ssh.source, DiscoverySource.tcpScan); + }); + + test('merges multiple ports for same IP', () async { + final svc = NetworkDiscoveryService( + connector: _fakeConnector({'10.0.0.1': [22, 2222]}), + mdnsFactory: _fakeMdns, + ); + final results = await svc + .scan( + _subnet('10.0.0.5', '10.0.0.0/24'), + ports: [22, 2222], + timeout: const Duration(milliseconds: 1), + ) + .toList(); + + final last = results.lastWhere((r) => r.ip == '10.0.0.1'); + expect(last.openPorts, containsAll([22, 2222])); + }); + + test('concurrency does not exceed 50', () async { + final log = []; + final svc = NetworkDiscoveryService( + connector: _countingConnector(log: log), + mdnsFactory: _fakeMdns, + ); + await svc + .scan( + _subnet('10.0.0.1', '10.0.0.0/24'), + ports: [22], + timeout: const Duration(milliseconds: 1), + ) + .drain(); + expect(log.isNotEmpty, true); + expect(log.reduce((a, b) => a > b ? a : b), lessThanOrEqualTo(50)); + }); + + test('onProgress reaches totalHosts at end', () async { + int lastScanned = 0, lastTotal = 0; + final svc = NetworkDiscoveryService( + connector: _fakeConnector({}), + mdnsFactory: _fakeMdns, + ); + await svc + .scan( + _subnet('192.168.1.1', '192.168.1.0/24'), + ports: [22], + timeout: const Duration(milliseconds: 1), + onProgress: (s, t) { + lastScanned = s; + lastTotal = t; + }, + ) + .drain(); + expect(lastScanned, lastTotal); + expect(lastTotal, 254); + }); + + test('cancel stops emitting', () async { + var emitted = 0; + final svc = NetworkDiscoveryService( + connector: (ip, port, _) async { + await Future.delayed(const Duration(milliseconds: 10)); + return true; + }, + mdnsFactory: _fakeMdns, + ); + final sub = svc + .scan( + _subnet('192.168.1.5', '192.168.1.0/24'), + ports: [22], + timeout: const Duration(milliseconds: 10), + ) + .listen((_) => emitted++); + await Future.delayed(const Duration(milliseconds: 5)); + svc.cancel(); + await Future.delayed(const Duration(milliseconds: 50)); + await sub.cancel(); + expect(emitted, greaterThanOrEqualTo(0)); + }); + }); + + group('DiscoveredHost', () { + test('merge combines ports and prefers existing hostname', () { + final a = DiscoveredHost( + ip: '10.0.0.1', + hostname: 'myhost', + openPorts: [22], + source: DiscoverySource.mdns); + final b = DiscoveredHost( + ip: '10.0.0.1', openPorts: [3389], source: DiscoverySource.tcpScan); + final m = a.merge(b); + expect(m.hostname, 'myhost'); + expect(m.openPorts, [22, 3389]); + expect(m.source, DiscoverySource.both); + }); + + test('isRdp true when only 3389 open', () { + final h = DiscoveredHost( + ip: '1.2.3.4', openPorts: [3389], source: DiscoverySource.tcpScan); + expect(h.isRdp, true); + }); + + test('isRdp false when 22 also open', () { + final h = DiscoveredHost( + ip: '1.2.3.4', + openPorts: [22, 3389], + source: DiscoverySource.tcpScan); + expect(h.isRdp, false); + }); + + test('portLabel returns SSH for port 22', () { + final h = DiscoveredHost( + ip: '1.2.3.4', openPorts: [22], source: DiscoverySource.tcpScan); + expect(h.portLabel, 'SSH'); + }); + + test('portLabel returns RDP for port 3389', () { + final h = DiscoveredHost( + ip: '1.2.3.4', openPorts: [3389], source: DiscoverySource.tcpScan); + expect(h.portLabel, 'RDP'); + }); + }); +} From e877395047fac454120b40361faf4729c682778e Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Mon, 8 Jun 2026 21:02:17 +0700 Subject: [PATCH 28/59] =?UTF-8?q?feat(discover):=20NetworkDiscoverySheet?= =?UTF-8?q?=20=E2=80=94=20bottom=20sheet=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/lib/widgets/network_discovery_sheet.dart | 495 +++++++++++++++++++ 1 file changed, 495 insertions(+) create mode 100644 app/lib/widgets/network_discovery_sheet.dart diff --git a/app/lib/widgets/network_discovery_sheet.dart b/app/lib/widgets/network_discovery_sheet.dart new file mode 100644 index 00000000..a0bb12dd --- /dev/null +++ b/app/lib/widgets/network_discovery_sheet.dart @@ -0,0 +1,495 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:uuid/uuid.dart'; +import '../models/discovered_host.dart'; +import '../models/host.dart'; +import '../providers/host_provider.dart'; +import '../providers/session_provider.dart'; +import '../services/network_discovery_service.dart'; +import '../theme/app_theme.dart'; +import 'host_detail_panel.dart'; + +class NetworkDiscoverySheet extends StatefulWidget { + /// When true, tapping a row calls [onSelected] and closes the sheet. + final bool selectionMode; + final void Function(DiscoveredHost)? onSelected; + + const NetworkDiscoverySheet({ + super.key, + this.selectionMode = false, + this.onSelected, + }); + + static void show( + BuildContext context, { + bool selectionMode = false, + void Function(DiscoveredHost)? onSelected, + }) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (_) => NetworkDiscoverySheet( + selectionMode: selectionMode, + onSelected: onSelected, + ), + ); + } + + @override + State createState() => NetworkDiscoverySheetState(); +} + +class NetworkDiscoverySheetState extends State { + final _svc = NetworkDiscoveryService(); + final _results = {}; + late final TextEditingController _subnetCtrl; + + List _subnets = []; + SubnetInfo? _selected; + bool _editingSubnet = false; + String? _subnetError; + + bool _scanning = false; + int _scanned = 0; + int _total = 0; + int _mdnsCount = 0; + int _tcpCount = 0; + + StreamSubscription? _sub; + + @override + void initState() { + super.initState(); + _subnetCtrl = TextEditingController(); + _loadSubnets(); + } + + @override + void dispose() { + _sub?.cancel(); + _svc.cancel(); + _subnetCtrl.dispose(); + super.dispose(); + } + + Future _loadSubnets() async { + final subnets = await _svc.getLocalSubnets(); + if (!mounted) return; + setState(() { + _subnets = subnets; + _selected = subnets.isNotEmpty ? subnets.first : null; + if (_selected != null) { + _subnetCtrl.text = _selected!.subnet; + } + }); + if (_selected != null) _startScan(); + } + + void _startScan() { + if (_selected == null) return; + _sub?.cancel(); + _svc.cancel(); + + final subnetStr = _subnetCtrl.text.trim(); + final subnet = _editingSubnet + ? SubnetInfo( + interfaceName: _selected!.interfaceName, + displayName: _selected!.displayName, + address: _selected!.address, + subnet: subnetStr, + ) + : _selected!; + + setState(() { + _scanning = true; + _scanned = 0; + _total = 0; + _mdnsCount = 0; + _tcpCount = 0; + _results.clear(); + }); + + _sub = _svc + .scan(subnet, onProgress: (s, t) { + if (mounted) setState(() { _scanned = s; _total = t; }); + }) + .listen( + (h) { + if (!mounted) return; + setState(() { + final isNew = !_results.containsKey(h.ip); + _results[h.ip] = h; + if (isNew) { + if (h.source == DiscoverySource.mdns) { + _mdnsCount++; + } else { + _tcpCount++; + } + } + }); + }, + onDone: () { if (mounted) setState(() => _scanning = false); }, + onError: (_) { if (mounted) setState(() => _scanning = false); }, + ); + } + + void _stopScan() { + _sub?.cancel(); + _svc.cancel(); + if (mounted) setState(() => _scanning = false); + } + + void _onAdd(BuildContext context, DiscoveredHost h) { + final port = + h.isRdp ? 3389 : (h.openPorts.contains(22) ? 22 : h.openPorts.first); + // Open HostDetailPanel on top of this sheet; onSave closes both. + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (_) => HostDetailPanel( + existing: null, + initialHost: h.ip, + initialPort: port, + initialLabel: h.hostname, + initialProtocol: h.isRdp ? HostProtocol.rdp : HostProtocol.ssh, + onClose: () => Navigator.of(context).pop(), + onSave: (host, password) async { + await context.read().addHost(host, password: password); + if (context.mounted) { + Navigator.of(context).pop(); // close HostDetailPanel + Navigator.of(context).pop(); // close DiscoverySheet + } + }, + ), + ); + } + + Future _onConnect(BuildContext context, DiscoveredHost h) async { + final hostProvider = context.read(); + final sessionProvider = context.read(); + Navigator.of(context).pop(); + + final port = h.isRdp ? 3389 : (h.openPorts.contains(22) ? 22 : h.openPorts.first); + final host = Host( + id: const Uuid().v4(), + label: h.hostname ?? h.ip, + host: h.ip, + port: port, + username: '', + protocol: h.isRdp ? HostProtocol.rdp : HostProtocol.ssh, + createdAt: DateTime.now(), + ); + await hostProvider.addHost(host); + if (context.mounted) sessionProvider.connectAny(host); + } + + @override + Widget build(BuildContext context) { + return DraggableScrollableSheet( + initialChildSize: 0.7, + minChildSize: 0.4, + maxChildSize: 0.95, + builder: (ctx, scroll) => Container( + decoration: const BoxDecoration( + color: AppColors.bg, + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + child: Column( + children: [ + _buildHandle(), + _buildHeader(context), + _buildSubnetBar(), + if (_scanning) _buildProgress(), + _buildCounterRow(), + const Divider(color: AppColors.border, height: 1), + Expanded(child: _buildResultList(context, scroll)), + ], + ), + ), + ); + } + + Widget _buildHandle() => Center( + child: Container( + width: 40, + height: 4, + margin: const EdgeInsets.symmetric(vertical: 8), + decoration: BoxDecoration( + color: AppColors.border, + borderRadius: BorderRadius.circular(2), + ), + ), + ); + + Widget _buildHeader(BuildContext context) => Padding( + padding: const EdgeInsets.fromLTRB(20, 4, 12, 8), + child: Row( + children: [ + const Icon(Icons.wifi_find, color: AppColors.accent, size: 18), + const SizedBox(width: 8), + const Text( + 'Discover Devices', + style: TextStyle( + color: AppColors.textPrimary, + fontSize: 15, + fontWeight: FontWeight.w600), + ), + const Spacer(), + IconButton( + icon: const Icon(Icons.close, size: 18), + color: AppColors.textSecondary, + onPressed: () => Navigator.of(context).pop(), + ), + ], + ), + ); + + Widget _buildSubnetBar() { + if (_subnets.isEmpty && _selected == null) { + return const Padding( + padding: EdgeInsets.fromLTRB(20, 0, 20, 8), + child: Text( + 'No active network interfaces found.', + style: TextStyle(color: AppColors.textSecondary, fontSize: 13), + ), + ); + } + return Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 8), + child: Row( + children: [ + if (_subnets.length > 1) + DropdownButton( + value: _selected, + dropdownColor: AppColors.card, + style: const TextStyle(color: AppColors.textPrimary, fontSize: 13), + underline: const SizedBox(), + items: _subnets + .map((s) => DropdownMenuItem(value: s, child: Text(s.displayName))) + .toList(), + onChanged: (s) { + if (s == null) return; + setState(() { + _selected = s; + _subnetCtrl.text = s.subnet; + _editingSubnet = false; + _subnetError = null; + }); + }, + ) + else + Text( + _selected?.displayName ?? '', + style: const TextStyle(color: AppColors.textSecondary, fontSize: 13), + ), + const SizedBox(width: 8), + Expanded( + child: TextField( + controller: _subnetCtrl, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 13, + fontFamily: 'monospace'), + decoration: InputDecoration( + isDense: true, + border: InputBorder.none, + errorText: _subnetError, + errorStyle: const TextStyle(fontSize: 10), + ), + onChanged: (v) { + setState(() { + _editingSubnet = true; + _subnetError = SubnetInfo.validateSubnet(v); + }); + }, + ), + ), + const SizedBox(width: 8), + TextButton( + onPressed: _subnetError != null + ? null + : () { + if (_scanning) _stopScan(); + _startScan(); + }, + child: Text( + _scanning ? 'Restart' : 'Scan', + style: const TextStyle(fontSize: 12), + ), + ), + ], + ), + ); + } + + Widget _buildProgress() { + final progress = _total > 0 ? _scanned / _total : 0.0; + return Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + LinearProgressIndicator( + value: progress, + backgroundColor: AppColors.border, + color: AppColors.accent, + ), + const SizedBox(height: 4), + Text( + _total > 0 ? 'Scanning… $_scanned / $_total' : 'Starting…', + style: const TextStyle(color: AppColors.textTertiary, fontSize: 11), + ), + ], + ), + ); + } + + Widget _buildCounterRow() => Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 8), + child: Text( + 'mDNS: $_mdnsCount found · TCP scan: $_tcpCount found', + style: const TextStyle(color: AppColors.textTertiary, fontSize: 11), + ), + ); + + Widget _buildResultList(BuildContext context, ScrollController scroll) { + final items = _results.values.toList() + ..sort((a, b) => a.ip.compareTo(b.ip)); + if (items.isEmpty) { + return const Center( + child: Text( + 'No devices found yet…', + style: TextStyle(color: AppColors.textTertiary), + ), + ); + } + return ListView.builder( + controller: scroll, + itemCount: items.length, + itemBuilder: (ctx, i) => _DiscoveredRow( + host: items[i], + selectionMode: widget.selectionMode, + onSelect: () { + widget.onSelected?.call(items[i]); + Navigator.of(context).pop(); + }, + onAdd: () => _onAdd(context, items[i]), + onConnect: () => _onConnect(context, items[i]), + ), + ); + } +} + +class _DiscoveredRow extends StatelessWidget { + final DiscoveredHost host; + final bool selectionMode; + final VoidCallback onSelect; + final VoidCallback onAdd; + final VoidCallback onConnect; + + const _DiscoveredRow({ + required this.host, + required this.selectionMode, + required this.onSelect, + required this.onAdd, + required this.onConnect, + }); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: selectionMode ? onSelect : null, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), + child: Row( + children: [ + Icon( + host.isRdp ? Icons.desktop_windows : Icons.computer, + size: 16, + color: AppColors.textSecondary, + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + host.hostname ?? host.ip, + style: const TextStyle( + color: AppColors.textPrimary, fontSize: 13), + ), + if (host.hostname != null) + Text( + host.ip, + style: const TextStyle( + color: AppColors.textTertiary, fontSize: 11), + ), + ], + ), + ), + _Badge(host.portLabel), + if (!selectionMode) ...[ + const SizedBox(width: 8), + _SmallBtn('Add', onAdd), + const SizedBox(width: 4), + _SmallBtn('Connect', onConnect, primary: true), + ], + ], + ), + ), + ); + } +} + +class _Badge extends StatelessWidget { + final String label; + const _Badge(this.label); + + @override + Widget build(BuildContext context) => Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(4), + border: Border.all(color: AppColors.border), + ), + child: Text( + label, + style: const TextStyle(color: AppColors.textSecondary, fontSize: 11), + ), + ); +} + +class _SmallBtn extends StatelessWidget { + final String label; + final VoidCallback onTap; + final bool primary; + + const _SmallBtn(this.label, this.onTap, {this.primary = false}); + + @override + Widget build(BuildContext context) => InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(4), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: primary ? AppColors.accent : AppColors.card, + borderRadius: BorderRadius.circular(4), + border: Border.all( + color: primary ? AppColors.accent : AppColors.border), + ), + child: Text( + label, + style: TextStyle( + color: primary ? Colors.white : AppColors.textPrimary, + fontSize: 11, + fontWeight: FontWeight.w500, + ), + ), + ), + ); +} From 8d7028398ee833bb2ff7fba27bf3e082e1d8c9b8 Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Mon, 8 Jun 2026 21:03:01 +0700 Subject: [PATCH 29/59] feat(discover): add initialHost/Port/Label/Protocol to HostDetailPanel --- app/lib/widgets/host_detail_panel.dart | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/app/lib/widgets/host_detail_panel.dart b/app/lib/widgets/host_detail_panel.dart index 3c83ebb1..0fa3d596 100644 --- a/app/lib/widgets/host_detail_panel.dart +++ b/app/lib/widgets/host_detail_panel.dart @@ -18,6 +18,10 @@ import 'terminal_appearance_controls.dart' show kBundledTerminalFonts; class HostDetailPanel extends StatefulWidget { final Host? existing; final String? initialGroup; + final String? initialHost; + final int? initialPort; + final String? initialLabel; + final HostProtocol? initialProtocol; final VoidCallback onClose; final Future Function(Host host, String password) onSave; final Future Function(Host host)? onConnect; @@ -30,6 +34,10 @@ class HostDetailPanel extends StatefulWidget { super.key, this.existing, this.initialGroup, + this.initialHost, + this.initialPort, + this.initialLabel, + this.initialProtocol, required this.onClose, required this.onSave, this.onConnect, @@ -82,15 +90,15 @@ class _HostDetailPanelState extends State { void initState() { super.initState(); final h = widget.existing; - _protocol = h?.protocol ?? HostProtocol.ssh; + _protocol = h?.protocol ?? widget.initialProtocol ?? HostProtocol.ssh; _domainCtrl = TextEditingController(text: h?.domain ?? ''); _rdpSecurity = h?.rdpSecurity ?? RdpSecurityMode.auto; - _hostCtrl = TextEditingController(text: h?.host ?? ''); - _labelCtrl = TextEditingController(text: h?.label ?? ''); + _hostCtrl = TextEditingController(text: h?.host ?? widget.initialHost ?? ''); + _labelCtrl = TextEditingController(text: h?.label ?? widget.initialLabel ?? ''); _groupCtrl = TextEditingController(text: h?.group ?? widget.initialGroup ?? ''); _tagsCtrl = TextEditingController(text: h?.tags.join(', ') ?? ''); - _portCtrl = - TextEditingController(text: (h?.port ?? _protocol.defaultPort).toString()); + _portCtrl = TextEditingController( + text: (h?.port ?? widget.initialPort ?? _protocol.defaultPort).toString()); _usernameCtrl = TextEditingController(text: h?.username ?? ''); _passwordCtrl = TextEditingController(); _authType = h?.authType ?? AuthType.password; From f8262b1db3be197681a998e08fd6368e5964024d Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Mon, 8 Jun 2026 21:04:18 +0700 Subject: [PATCH 30/59] feat(discover): Discover button on Hosts Dashboard toolbar --- app/lib/screens/main_screen.dart | 2 ++ app/lib/widgets/hosts_dashboard.dart | 12 +++++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/app/lib/screens/main_screen.dart b/app/lib/screens/main_screen.dart index 4e175d89..0129bdc8 100644 --- a/app/lib/screens/main_screen.dart +++ b/app/lib/screens/main_screen.dart @@ -53,6 +53,7 @@ import '../widgets/session_tab.dart'; import '../models/rdp_session.dart'; import '../widgets/rdp_workspace.dart'; import '../services/storage_service.dart'; +import '../widgets/network_discovery_sheet.dart'; enum NavSection { hosts, keychain, portForwarding, sftp, knownHosts, recordings, audit, settings, plugins } @@ -882,6 +883,7 @@ class _MainScreenState extends State with WidgetsBindingObserver { onOpenLocalTerminal: _openLocalTerminal, onNewGroup: _openNewGroupPanel, onImport: _openImportPanel, + onDiscover: () => NetworkDiscoverySheet.show(context), ), NavSection.keychain => const KeychainScreen(), NavSection.portForwarding => const PortForwardingScreen(), diff --git a/app/lib/widgets/hosts_dashboard.dart b/app/lib/widgets/hosts_dashboard.dart index 406522f3..c9ab0815 100644 --- a/app/lib/widgets/hosts_dashboard.dart +++ b/app/lib/widgets/hosts_dashboard.dart @@ -32,7 +32,8 @@ class HostsDashboard extends StatefulWidget { final VoidCallback? onOpenLocalTerminal; final VoidCallback? onNewGroup; final VoidCallback? onImport; - const HostsDashboard({super.key, this.onAddHost, this.onEditHost, this.onOpenLocalTerminal, this.onNewGroup, this.onImport}); + final VoidCallback? onDiscover; + const HostsDashboard({super.key, this.onAddHost, this.onEditHost, this.onOpenLocalTerminal, this.onNewGroup, this.onImport, this.onDiscover}); @override State createState() => _HostsDashboardState(); @@ -237,6 +238,7 @@ class _HostsDashboardState extends State { onLocalTerminal: widget.onOpenLocalTerminal, onNewGroup: widget.onNewGroup, onImport: widget.onImport, + onDiscover: widget.onDiscover, onSelect: _enterSelectionMode, sortMode: sortMode, onSortChanged: (m) => @@ -329,6 +331,7 @@ class _TopBar extends StatelessWidget { final VoidCallback? onLocalTerminal; final VoidCallback? onNewGroup; final VoidCallback? onImport; + final VoidCallback? onDiscover; final VoidCallback? onSelect; final HostSortMode sortMode; final ValueChanged onSortChanged; @@ -344,6 +347,7 @@ class _TopBar extends StatelessWidget { this.onLocalTerminal, this.onNewGroup, this.onImport, + this.onDiscover, this.onSelect, required this.sortMode, required this.onSortChanged, @@ -409,6 +413,12 @@ class _TopBar extends StatelessWidget { const SizedBox(width: 8), _ViewToggle(viewMode: viewMode, onChanged: onViewChanged), const SizedBox(width: 8), + _OutlinedBtn( + icon: Icons.wifi_find, + label: 'DISCOVER', + onTap: onDiscover ?? () {}, + ), + const SizedBox(width: 8), _OutlinedBtn( icon: Icons.check_box_outlined, label: 'SELECT', From 01b62432590870c612eaf6b14b0a0c536b3dcbae Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Mon, 8 Jun 2026 21:04:57 +0700 Subject: [PATCH 31/59] feat(discover): Scan network link in Add Host panel --- app/lib/widgets/host_detail_panel.dart | 40 ++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/app/lib/widgets/host_detail_panel.dart b/app/lib/widgets/host_detail_panel.dart index 0fa3d596..24da729c 100644 --- a/app/lib/widgets/host_detail_panel.dart +++ b/app/lib/widgets/host_detail_panel.dart @@ -12,6 +12,7 @@ import '../theme/app_theme.dart'; import '../theme/terminal_themes.dart'; import 'agent_status_line.dart'; import 'host_chain_editor.dart'; +import 'network_discovery_sheet.dart'; import 'rdp_badge.dart'; import 'terminal_appearance_controls.dart' show kBundledTerminalFonts; @@ -351,6 +352,45 @@ class _HostDetailPanelState extends State { _Card(children: [ _AddressField(controller: _hostCtrl), ]), + if (_isNew) ...[ + const SizedBox(height: 4), + Align( + alignment: Alignment.centerRight, + child: TextButton.icon( + icon: const Icon(Icons.wifi_find, size: 13), + label: const Text('Scan network to pick a device', + style: TextStyle(fontSize: 12)), + style: TextButton.styleFrom( + foregroundColor: AppColors.textSecondary, + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 4), + ), + onPressed: () => NetworkDiscoverySheet.show( + context, + selectionMode: true, + onSelected: (h) { + setState(() { + _hostCtrl.text = h.ip; + _portCtrl.text = (h.isRdp + ? 3389 + : (h.openPorts.contains(22) + ? 22 + : h.openPorts.first)) + .toString(); + if (h.hostname != null && + _labelCtrl.text.isEmpty) { + _labelCtrl.text = h.hostname!; + } + if (h.isRdp && + _protocol != HostProtocol.rdp) { + _onProtocolChanged(HostProtocol.rdp); + } + }); + }, + ), + ), + ), + ], const SizedBox(height: 16), _sectionLabel('GENERAL'), From dd37b8011ff98bbbbc34e242b47a4b33996fb93f Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Mon, 8 Jun 2026 21:09:10 +0700 Subject: [PATCH 32/59] =?UTF-8?q?docs(spec):=20import=20sources=20expansio?= =?UTF-8?q?n=20=E2=80=94=209-source=20picker=20+=20parser=20architecture?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../specs/2026-06-08-import-sources-design.md | 302 ++++++++++++++++++ 1 file changed, 302 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-08-import-sources-design.md diff --git a/docs/superpowers/specs/2026-06-08-import-sources-design.md b/docs/superpowers/specs/2026-06-08-import-sources-design.md new file mode 100644 index 00000000..63dc9a9a --- /dev/null +++ b/docs/superpowers/specs/2026-06-08-import-sources-design.md @@ -0,0 +1,302 @@ +# Import Sources Expansion — Design Spec + +**Date:** 2026-06-08 +**Status:** Approved + +## Overview + +Expand the Import Hosts panel from a format-agnostic file/paste picker into a source-aware flow with 9 named import sources, a grid source-picker UI, and dedicated parsers for each format. + +## Scope + +**In scope:** +- Source-picker grid UI (9 sources) +- Parser extraction into `app/lib/util/import_parsers.dart` +- New parsers: PuTTY (.reg), MobaXterm (.mxtsessions), SecureCRT (XML), Ansible (INI), WinSCP (.ini), Termius (JSON), SSH URI +- Existing parsers migrated to the new abstraction: SSH config, CSV +- Per-source file extension hints and help text + +**Out of scope:** +- Ansible YAML inventory (deferred — complexity disproportionate to benefit) +- Vendor logo SVG assets (use Material icons) +- Cloud-based import (AWS, Tailscale, etc.) + +--- + +## 1. Parser Architecture + +### File +`app/lib/util/import_parsers.dart` + +### Types + +```dart +typedef ParseResult = ({List hosts, List warnings}); + +abstract class ImportParser { + ParseResult parse(String input); +} +``` + +All parsers return `ParseResult`. Single-host parse errors are collected in `warnings` and skipped; structural errors (unrecognised format) return `hosts: [], warnings: ['']`. + +### Source registry + +```dart +enum ImportSource { + sshConfig, csv, putty, mobaXterm, secureCrt, + ansible, winScp, termius, sshUri, +} + +class ImportSourceDef { + final ImportSource source; + final String label; + final IconData icon; + final Color iconColor; + final List fileExtensions; // passed to FilePicker allowedExtensions + final String hint; // shown below file/paste area + final ImportParser parser; +} +``` + +`ImportSourceDef.all` — ordered list of all 9 definitions (order = grid display order). + +--- + +## 2. UI Flow + +``` +ImportPanel + ├── [Step: sourcePicker] _selectedSource == null + │ └── 3-column grid of ImportSourceCard + └── [Step: input + preview] _selectedSource != null + ├── Header: "← Import from