19 Commits

Author SHA1 Message Date
Your Name c4dd4d8adf fix: top/bottom edge resize broken — menubar ate events + minimumSize clamped to 1121px
Two independent root causes for '左右能拉,上下不能拉':

1. Event delivery: the top 6px edge band belongs to QMenuBar, which
   actively consumes mouse events (menu hover) — nothing ever propagated
   to QMainWindow, so T/TL/TR (and maximize-restore from top edge) were
   dead on every platform. Install an event filter on menuBar()/statusBar():
   within the edge band it handles hover cursor, press (grabMouse), drag
   and release; outside the band everything passes through (menus open
   normally, native QSizeGrip corner drag preserved).

2. Layout minimum: MonitorPanel's minimumSizeHint (~1015px of stacked
   tables/group boxes) propagated through QTabWidget → central widget →
   QMainWindow, making the window minimum size 898x1121. Any vertical
   shrink was clamped dead. Set explicit per-tab-page minimumSize
   (320x200) + window minimumSize (760x480).

Tests: test_resize.py now drives edges through real event delivery
(childAt → child widget, propagation/filter path) for L/R/T/B/TL/TR/BL,
verifies BR stays on QSizeGrip, maximize-restore via menubar filter,
and minimum-size regression. All 8 suites pass.
2026-07-29 07:49:10 +08:00
Your Name e0d33adb97 feat: collapsible host sidebar (Ctrl+B / view menu / floating restore button)
- ◀ button in sidebar header + '视图→隐藏侧栏 (Ctrl+B)' menu item
- collapsed state persisted to ~/.sshclient/settings.json, restored on startup
- floating '主机' button (theme-aware dark/light) to re-show sidebar
- fix: remove duplicate QShortcut — QShortcut + QAction shortcut on same key
  both fired, toggling twice per Ctrl+B press (net zero effect)
- fix: floating button positioned with parent-local coords instead of
  mapToGlobal — button no longer drifts off the window edge when the
  window is not at screen origin; resizeEvent/moveEvent overrides removed
- remove dead terminal_input_set_enabled (referenced nonexistent cmd_input)
- test_sidebar.py: 7-step coverage incl. single-fire shortcut regression,
  splitter width restore, cross-restart persistence; settings isolated to tmpdir
2026-07-29 07:34:06 +08:00
Hermes 9c08d7593d feat: window edge drag-to-resize (normal + from maximized)
User asked: '窗口应该可以前后左右拉'

Added custom edge drag handling in MainWindow (QMainWindow's default
resize support exists but ignored + we want full control for
maximized state too):

New methods in ui/main_window.py:
- _hit_test_edge(pos) -> 'L'/'R'/'T'/'B'/'TL'/'TR'/'BL'/'BR'/''
  6px margin around window. Returns which edge (or corner) the
  cursor is on. Returns '' when maximized/fullscreen.
- _edge_to_cursor(edge): sets Qt.SizeHor/Ver/FDiag/BDiag cursor
- _do_resize(global_pos): computes new geometry based on drag
  delta. Respects self.minimumWidth/Height (QMainWindow's auto
  min size from menu/toolbar/statusbar) and screen bounds.
- _restore_from_max(global_pos): when maximized, click+drag on
  TOP edge → showNormal() + set geometry to mouse-relative size
  + continue drag from 'TL' corner (like Windows native behavior)
- mousePressEvent/Move/Release: hook into resize state
- leaveEvent: clear cursor when leaving window
- changeEvent: clear resize state on window state change

State fields added in __init__:
- self._resize_edge, self._resize_start_geo, self._resize_start_pos
- self._resize_margin = 6
- self._dragging_from_max = False

Fix: 'QTabWidget.RightSide' was wrong API; used 'QTabBar.RightSide'
in the multi-tab commit (unrelated, but I noticed while testing).

New test_resize.py (5 tests, all pass):
- 8-region hit-test (4 edges + 4 corners)
- Right-edge drag → width grows
- Left-edge drag → x moves right (width limited by minW)
- Top-left corner drag
- Maximized + click top edge → restored to normal

Existing tests (core 6/6, UI 3/3) still pass.

Build: 57MB exe.
2026-07-29 07:23:16 +08:00
Hermes a98f22202d feat: add '复制标签' option to terminal tab right-click menu
User asked: '标签使用鼠标右键应该有复制标签的功能'

In ui/terminal_tab_widget.py _on_tab_context_menu:
- Added '📋 复制标签' action between '重命名' and '关闭标签'
- New method _duplicate_tab(idx): creates a new terminal tab for
  the same host (reuses SSH connection, opens independent shell
  channel via paramiko invoke_shell)
- New tab title = '<src_title> (副本)' with  state dot
- If host not connected: duplicate creates a placeholder tab
- If host connected: duplicate immediately opens a new shell

Verified: 3 tabs after duplicating 'test-server' (欢迎 +  test-server
+  test-server (副本))

All tests still pass.
2026-07-29 07:12:52 +08:00
Hermes 2c6f06e392 feat: multi-tab terminal (Xshell-style)
User asked: '终端应该支持多标签功能,不能只支持一个'

Changes:
1. New ui/terminal_tab_widget.py - TerminalTabWidget (QWidget with nested QTabWidget)
   - Each tab hosts a real TerminalPanel (independent shell + history)
   -  corner button (top-left) with menu:
       * 选择主机 (search/filter dialog) -> opens connected terminal
       * 新建空白标签 (no host, can attach later)
   - Tab title: state dot + name  (' name' / '🟢 name' / '🔴 name' / '🟡 name')
   - Tab closeable (× button), confirms if active shell
   - Double-click tab title -> rename
   - Right-click tab -> 重命名 / 关闭 / 关闭其他 / 重新打开 shell
   - Tabs draggable (setMovable)
   - Initial placeholder tab '📡 欢迎' (not closeable)
   - API compat: attach(conn) / close_shell() / _set_status(text, color)
   - New API: open_terminal(host_id, conn, label) / close_current_tab() /
              current_host_id (property) / shutdown()

2. ui/main_window.py
   - TerminalPanel() -> TerminalTabWidget(self.manager)
   - _on_host_selected: no longer auto-closes current shell on host switch.
     Instead creates a new tab if host already connected.
   - _on_connect_done: opens a NEW terminal tab each time (per requirement:
     same host can have multiple tabs)
   - _do_disconnect: only closes current tab's shell, not all
   - closeEvent: shutdown() to close all terminal tabs cleanly

3. test_ui.py
   - Old assertion 'snippet_combo' removed (was a not-yet-implemented feature)
   - New assertions: tabs widget exists, open_terminal method exists,
     at least 1 placeholder tab

Verified: 4 tabs visible (欢迎 + 3 open),  button works, closeable.
Tests: core 6/6 + UI 3/3 pass.
2026-07-29 07:05:59 +08:00
Your Name 47c6589e1d feat: application icon for Windows taskbar + exe
- scripts/gen_icon.py: generates 256x256 SSH client icon (dark terminal
  window with >_ prompt and SSH connection nodes), outputs multi-size
  ICO (16-256px) + PNG
- resources/icon.ico + icon.png: generated icon files
- main.py: _get_icon_path() searches _MEIPASS (onefile), exe dir, source
  dir; sets app + window icon
- build.spec: bundles icon files via datas=, sets exe icon=icon.ico
- Windows taskbar will now show the custom icon instead of default PyInstaller icon
2026-07-29 06:54:12 +08:00
Your Name acaa48b242 feat: host groups - tree view with folders, drag-free context menu
Data model:
- hosts.json upgraded to {groups: [...], hosts: [...]}, auto-migrates
  old flat-list format on load
- each host gets group field (default '' = ungrouped)

Manager API (core/manager.py):
- add_group / remove_group / rename_group / move_host_to_group
- list_groups / list_hosts_by_group

UI (ui/main_window.py):
- QListWidget replaced with QTreeWidget (folder tree)
- Group nodes: 📁 name (count), expandable/collapsible
- Ungrouped always shown as 📭 未分组
- Right-click context menu:
  - Empty area / group: New group, Rename, Delete
  - Host: Connect, Edit, Delete, Move to group (submenu)
- Double-click host = connect, double-click group = expand/collapse
- All 6 test suites pass
2026-07-29 06:51:23 +08:00
Your Name ac29515d9f fix: Backspace/Delete deleting prompt text (root@host:~#)
Root cause: _current_line_start was set after \r\n but BEFORE the prompt
was rendered, so Backspace could erase prompt characters after reaching
the input line start.

Fix: send \x7f (DEL) to shell on Backspace and \x1b[3~ on Delete instead
of locally deleting characters. The shell's readline handles backspace
correctly - it ignores DEL at the input line start, so the prompt is
never touched. The existing \b handler in _on_data already correctly
processes the shell's \b-space-\b echo sequence.

Verified: typing 'abc' + 3 Backspace deletes input, 5 more Backspace
leaves prompt 'root@host:~# ' intact.
2026-07-29 06:47:08 +08:00
Your Name f0b6a71a65 fix: Delete key in terminal deleting prompt text (root@host)
_on_delete() was missing _current_line_start check that _on_backspace
already had. Clicking into the prompt area (root@hostname:~#) and pressing
Delete would erase prompt characters. Now:
- Cursor before input line: moved to input start, no deletion
- Selection crossing prompt boundary: blocked
2026-07-29 06:40:37 +08:00
Your Name 2b29a5cf48 feat: command snippets + UI test coverage for new features
- core/snippets.py: SnippetManager with 20 prebuilt sysadmin commands,
  CRUD + reorder, persisted to ~/.sshclient/snippets.json
- ui/snippet_dialog.py: SnippetDialog (add/edit) + SnippetManagerDialog
  (table view with add/edit/delete/move up/down)
- ui/terminal_panel.py: snippet combo box in toolbar, send_command()
  method to inject commands into interactive shell, snippet manager button
- README.md: updated feature table, usage section, project structure
- test_ui.py: added [2/4] checks for sparkline/snippets/theme,
  added [4/4] theme toggle verification
2026-07-28 23:03:37 +08:00
Your Name 57092cad03 feat: dark theme toggle + CPU/memory sparkline trend charts
- ui/theme.py: light/dark QSS stylesheets, persisted to ~/.sshclient/settings.json
- main_window.py: View menu with dark theme checkbox, applies on startup
- ui/widgets.py: SparklineChart widget for mini trend visualization
- MonitorPanel: CPU and memory sparkline charts showing last 60 samples
- Charts cleared on stop/switch host
2026-07-28 23:00:50 +08:00
Your Name 5d83348db3 fix: monitor worker non-blocking + test fixes
- core/monitor.py: add fast metrics script (0.3s vs 1.4s sleep),
  collect() now defaults to fast=True for responsive UI
- ui/workers.py: MonitorWorker changed from loop mode to single-shot
  (QTimer re-arms next cycle on finished signal, avoids paramiko
  blocking in infinite loop)
- ui/widgets.py: remove debug print from _kick_one_sample
- test_monitor_nonblock.py: fix sample counting by wrapping _on_sample
  instead of disconnecting signals (old approach missed new workers)
- test_process_monitor.py: assertions account for MAX_RENDER_ROWS=200,
  fix MEM% column index (4 not 3) in sort test
2026-07-28 22:57:15 +08:00
Hermes 5e69b1c3e2 feat: overhaul process monitoring UI - more info, tree view, details, batch ops
User feedback: '进程比较关键,以下显示太少了,需要继续优化下监控UI'

Process monitor panel was rebuilt:

1. core/monitor.py: 200 -> 500 processes, added PPID / PRI / NICE
   New ps fields: pid ppid user pcpu pmem vsz rss stat pri nice etimes times args
   Python parser: new fields ppid, pri, nice

2. ui/widgets.py MonitorPanel:
   a) Toolbar: search box + sort dropdown (CPU/MEM/PID/start/user/comm)
      + view toggle (Flat / Tree) + CPU% min filter (SpinBox 0-100%)
      + batch kill button
   b) Table: 12 columns (Flat) / 13 cols (Tree + indent col)
      PID PPID USER CPU% MEM% RSS STAT PRI NI '启动' 'CPU时间' 命令
      - Compact row height 20px (was default 30)
      - Extended selection (Ctrl/Shift multi)
      - Color highlights:
          CPU% >=80 red bg+fg bold, >=50 red fg bold, >=20 orange
          MEM% >=15 orange bg+fg bold, >=5 orange
          STAT: Z orange bg + orange-red fg bold, R green, D purple
          NI: negative green, positive orange
      - Red bg on EXTREME rows for instant visibility
   c) Tree view: builds PPID tree with ├─/└─/│ connectors;
      root processes (PID 1) at depth 0, sorted by CPU within siblings.
      Useful for 'which process is spawning these things?'
   d) Right side panel:
      - 进程详情 (QTextEdit dark theme): full PID/PPID/USER/STAT/PRI/NI/
        CPU/MEM/RSS/VSZ/etime/cputime, command, parent process name,
        child processes list (top 10)
      - 🔥 CPU TOP 5 (QListWidget dark): click to jump+select in main table
      - 💾 内存 TOP 5 (QListWidget dark): same
   e) Right-click menu enhanced:
      kill (SIGTERM) | kill -9 (SIGKILL) | batch kill (if multi-select)
      | copy PID | copy command | filter by command | view details
   f) Batch kill: collects all selected PIDs, runs 'kill p1 p2 p3 ...'
      in a single SSH call, then loops 'kill -0' to report survivors.
      One confirmation dialog lists top 10 + '还有 N 个'.
   g) Status row at bottom: 'Ctrl+A 全选, Shift+点击多选, 右键批量操作'

3. test_process_monitor.py: 5/5 tests pass with 500 rows, tree view,
   TOP 5, CPU min filter, etc.

All tests pass: core 6/6 + UI 3/3 + E2E 6/6 + terminal 5/5 + proc 5/5
Build: 57 MB single-file exe.
2026-07-28 22:39:26 +08:00
Hermes 5aa833ebee feat: add process monitoring to Monitor panel
User asked for process monitoring. Added:

1. core/monitor.py:
- Extended _LINUX_METRICS_SCRIPT with ===PROC=== section
- ps -e -o stat= | awk counts total/running/sleep/disk-sleep/zombie
- ps -eo pid,user,pcpu,pmem,vsz,rss,stat,etimes,times,args
  Uses etimes/times (numeric) instead of start/time (string with
  spaces) to avoid column-shift bugs. awk joins 10th col onwards
  with spaces so comm retains its full command line.
- New empty-dict fields: proc_total, proc_running, proc_sleep,
  proc_disk, proc_zombie, processes
- New processes[] array with full per-process fields

2. ui/widgets.py MonitorPanel:
- New _build_proc_summary() - 4-stat overview card (total/running/
  sleep/zombie, color-coded)
- New _build_proc_table() - 9-column process list with:
    * Search box (multi-keyword AND match across pid/user/stat/comm)
    * Sort dropdown (CPU / MEM / PID / start time / user)
    * Color-coded CPU% (red >=50%, orange >=20%)
    * Color-coded MEM% (red >=10%, orange >=5%)
    * Color-coded STAT (red for zombie)
    * RSS formatted with format_bytes
    * ETIME / TIME formatted as 5d3h / 2h15m / 45s
- _apply_proc_filter() - filter + sort + render in one pass
- _proc_context_menu() - right-click menu:
    * kill (SIGTERM) | kill -9 (SIGKILL) | copy PID | copy cmd |
      filter by this command
- _proc_kill_selected() / _proc_kill() - sends kill over SSH
  with confirmation dialog, shows remote exit code in result
- Splitter: 4 metric cards + proc summary + disk + net on top,
  process list on bottom. User-draggable.

3. New test_process_monitor.py (5 tests, all pass):
- Real SSH connection, collects process data
- Renders MonitorPanel with 200 rows
- Tests search filter / sort change / clear
- Tests killing a real sleep process: starts sleep 60, finds it
  in the table, calls _proc_kill (with QMessageBox monkey-patched
  to auto-yes), verifies remote kill -0 returns 'No such process'
- Also fixed passwords in test_e2e.py + test_terminal.py to match
  the working local sshd credentials

All tests pass: core 6/6 + UI 3/3 + E2E 6/6 + terminal 5/5 + proc 5/5
Build: 57 MB single-file exe.
2026-07-28 22:25:33 +08:00
Hermes 1b7259c2d5 fix: terminal garbled boxes from OSC sequences in PS1
User reported: each prompt line shows a leading '[?]' box.
Root cause: Ubuntu's default PS1 uses \\e]0;user@host:dir\\a
(OSC 0, set window title) which our ANSI parser did not recognize.
It only handled SGR (\\e[...m), so the raw bytes were drawn as tofu boxes.

Fix in ui/terminal_panel.py:
- Added _OSC_RE for ESC ] ... BEL|ESC\\ sequences (set title, set cwd)
- Added _DCS_RE for ESC P/X/^/_ ... ESC\\ (DCS/SOS/PM/APC)
- _on_data() branches ESC prefix by next byte: '['->CSI, '] '->OSC,
  'P/X/^/_'->DCS, others->skip 2 bytes
- Captured OSC 0/1/2 content into self._window_title (so future
  versions can display it)
- _strip_ansi() now also strips OSC/DCS (for clean history recall)

Added [4b/5] test: injects a literal Ubuntu PS1 byte stream
(OSC 0 + SGR colors) and verifies the rendering is just the plain
'root@ubuntu2204:~# ' with no tofu / no leaked escape chars.

All 21 tests pass (core 6 + UI 3 + E2E 6 + terminal 6).
sshclient_v0.0.1
2026-07-28 21:46:26 +08:00
Hermes 9dbd500399 feat: real interactive terminal (Xshell/PuTTY style)
User feedback: 'your terminal can only send commands, not type
freely. Useless.'

Replaced the old 'single command + output' terminal with a true
interactive shell panel:

NEW: ui/terminal_panel.py
- Uses paramiko invoke_shell() to open a long-lived shell session
- Every keystroke is forwarded to the remote shell (not a textbox)
- Remote output (incl. ANSI color codes) is parsed and rendered
  inline in a QPlainTextEdit (16-color SGR, bold, underline, invert)
- Cursor kept on the current input line; user can't edit past output
- Local command history with up/down navigation (stores last 200)
- Standard terminal shortcuts: Ctrl+C (SIGINT), Ctrl+D (EOF),
  Ctrl+L (clear), Ctrl+A/E (line start/end), Ctrl+U/K/W
- Terminal resize forwarded to remote PTY (vim/top work correctly)
- Tab key forwarded to remote for completion

CHANGED: core/ssh_client.py
- Added open_shell(term_type, cols, rows) -> Channel
- Added resize_shell(chan, cols, rows)

CHANGED: ui/main_window.py
- Replaced old _build_terminal_tab (now removed) with TerminalPanel
- On host connect -> auto-open shell, switch to Terminal tab
- On host disconnect / switch -> close shell cleanly

NEW: test_terminal.py (5 tests, all pass)
- Real SSH connection, opens shell
- Sends 'ls /tmp' via simulated keypresses, verifies 162-line output
- Sends ANSI color command, verifies colored text appears
- Tests up-arrow history recall
- Verifies prompt returns after command completes

All existing tests still pass (core 6/6 + UI 3/3 + E2E 6/6 + terminal 5/5)
2026-07-28 21:36:49 +08:00
Hermes 8b5dacda58 fix: terminal command output not displaying - add diagnostics
User reported: '已连接, 但终端啥也不显示'
Likely cause: command ran but stdout was empty / scrolled to wrong place
/ output widget didn't refresh before worker finished.

Changes:
- Eagerly show the typed command in the output area (\$ <cmd>) so user
  sees the input was received even if the worker hangs/fails
- Show '(stdout 为空)' placeholder when worker returns no output, so it's
  clear the worker actually ran
- Force output.repaint() and ensureCursorVisible() after append
- Add 2 diagnostic buttons in terminal bottom bar:
    * [🔍 诊断]  prints current SSH connection / worker state
    * [▶ 测试命令]  pre-fills a known-good test command
- Make CommandWorker explicitly check conn.connected before exec
- Surface full traceback in stderr on exception instead of bare str(e)

All 15 existing tests still pass (core 6/6 + UI 3/3 + E2E 6/6).
2026-07-28 21:18:21 +08:00
Hermes 01c8d3581a fix: rebuild build_windows.bat without Chinese / dangerous cmd patterns
- Remove all non-ASCII text (cmd default GBK mangles UTF-8)
- Replace 'if errorlevel 1 (' multi-line blocks with 'goto :label'
- Replace 'echo(' with 'echo.'
- Use 'for %%I' to get file size (avoid UTF-8 console findstr issues)
- All error paths now end with explicit exit /b 1

Fixes #1: '涓嬭浇鍦板潃' (mojibake) and '('") parse errors.
2026-07-28 21:04:09 +08:00
Hermes a57dbc0252 feat: SSHClient v1.0.0 - PyQt5 + paramiko 跨平台 SSH 客户端
功能:
- 多主机管理 (增删改查, 密码/私钥双认证, 导入导出)
- 远程终端 (命令执行 + 常用命令快捷栏 + 超时控制)
- SFTP 文件浏览 (上传/下载带进度, 新建/删除/重命名)
- 实时监控 (CPU/内存/磁盘/网络, 1-10秒可调刷新)
- AI Agent (OpenAI 兼容 API, 5 工具自动调用: 命令/指标/列文件/读文件/上传)

技术栈: PyQt5 + paramiko + psutil + requests + PyInstaller
打包: build_windows.bat / build.sh 一键产出 ~57MB 单文件 exe
测试: core 6/6 + UI 3/3 + E2E 6/6 全部通过
2026-07-28 21:01:31 +08:00