@linxin666/dsh-ssh 0.1.2 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@linxin666/dsh-ssh",
3
3
  "description": "Remote SSH operations for the dsh web GUI: host config store (~/.dsh/dsh-ssh.json, import from ~/.ssh/config), a persistent ssh2 connection pool with jump-host support, exec / PTY web terminal / SFTP transfer / local port-forward tunnels / cluster execution, plus agent tools (ssh_list, ssh_exec, ssh_upload, ssh_download, ssh_tunnel, ssh_cluster). Hot-pluggable — mounted via ~/.dsh/cordis.patch.yml + a profile node_modules symlink, no dsh source changes.",
4
- "version": "0.1.2",
4
+ "version": "0.1.6",
5
5
  "type": "module",
6
6
  "engines": {
7
7
  "node": "^22.19.0 || >=24.0.0"
@@ -20,6 +20,11 @@ export const PANEL_VIEW_SELECTOR = '[data-dsh-ssh-view]'
20
20
 
21
21
  const CONVERSATION_COLUMN_SELECTOR = '[data-pane="conversation"]'
22
22
  const ACTIVE_ATTR = 'data-dsh-ssh-active'
23
+ /** The sibling panel's activation attribute (task board), removed when this panel opens. */
24
+ const OTHER_ACTIVE_ATTR = 'data-dsh-taskboard-active'
25
+ /** Cross-plugin activation event; detail is the activating panel name. */
26
+ const ACTIVATE_EVENT = 'dsh-panel-activate'
27
+ const PANEL_NAME = 'ssh'
23
28
 
24
29
  /** Find the center column, or undefined while the frame is not mounted. */
25
30
  function conversationColumn(): HTMLElement | undefined {
@@ -62,16 +67,42 @@ export function mountPanel(controller: PanelController, api: SshApi): () => void
62
67
 
63
68
  const applyActive = (): void => {
64
69
  if (controller.getSnapshot().panelOpen) {
70
+ // Single-occupant center column: opening this panel must evict the
71
+ // sibling panel (task board), both its html attribute and its
72
+ // controller state, otherwise the two panels' visibility rules fight
73
+ // and the second click appears dead.
74
+ document.documentElement.removeAttribute(OTHER_ACTIVE_ATTR)
65
75
  document.documentElement.setAttribute(ACTIVE_ATTR, '')
76
+ document.dispatchEvent(new CustomEvent(ACTIVATE_EVENT, { detail: PANEL_NAME }))
66
77
  } else {
67
78
  document.documentElement.removeAttribute(ACTIVE_ATTR)
68
79
  }
69
80
  }
81
+ const onOtherActivate = (event: Event): void => {
82
+ if ((event as CustomEvent).detail === 'taskboard' && controller.getSnapshot().panelOpen) {
83
+ controller.close()
84
+ }
85
+ }
86
+ // Jump out on sidebar context clicks: clicking a session/workspace row
87
+ // (including the already-current one, which produces no session-change
88
+ // event) hands the center column back to the conversation. Capture phase,
89
+ // so the panel closes before the shell processes the click.
90
+ const SIDEBAR_ROW_SELECTOR = '[class*="sessionRow"], [class*="projectRow"], [class*="searchResultRow"], [class*="searchResultWorkspace"], [class*="newSession"]'
91
+ const onClickSidebarRow = (event: MouseEvent): void => {
92
+ if (!controller.getSnapshot().panelOpen) return
93
+ const target = event.target as HTMLElement | null
94
+ if (target === null) return
95
+ if (target.closest(SIDEBAR_ROW_SELECTOR) !== null) controller.close()
96
+ }
97
+ document.addEventListener('click', onClickSidebarRow, true)
98
+ document.addEventListener(ACTIVATE_EVENT, onOtherActivate)
70
99
  const unsubscribe = controller.subscribe(applyActive)
71
100
  applyActive()
72
101
  ensure()
73
102
 
74
103
  return () => {
104
+ document.removeEventListener('click', onClickSidebarRow, true)
105
+ document.removeEventListener(ACTIVATE_EVENT, onOtherActivate)
75
106
  waitObserver.disconnect()
76
107
  unsubscribe()
77
108
  document.documentElement.removeAttribute(ACTIVE_ATTR)
@@ -21,13 +21,16 @@
21
21
  z-index: 5;
22
22
  }
23
23
 
24
- html[data-dsh-ssh-active] [data-dsh-ssh-view] {
24
+ /* The center column is single-occupant; the :not() guards keep the two
25
+ sibling panels (task board / ssh) from fighting over visibility if both
26
+ activation attributes ever coexist. */
27
+ html[data-dsh-ssh-active]:not([data-dsh-taskboard-active]) [data-dsh-ssh-view] {
25
28
  display: block;
26
29
  }
27
30
 
28
31
  /* While the panel is active, the conversation content underneath is hidden
29
32
  (it stays mounted and stateful). */
30
- html[data-dsh-ssh-active] [data-pane='conversation'] > :not([data-dsh-ssh-view]) {
33
+ html[data-dsh-ssh-active]:not([data-dsh-taskboard-active]) [data-pane='conversation'] > :not([data-dsh-ssh-view]) {
31
34
  display: none;
32
35
  }
33
36
 
@@ -19,21 +19,24 @@ import css from './panel/panel.module.css'
19
19
  /** Stable data attribute identifying the injected entry row. */
20
20
  export const ENTRY_SELECTOR = '[data-dsh-ssh-entry]'
21
21
 
22
- /** The sidebar column is the grid item AppFrame renders with this pane marker. */
23
- const SIDEBAR_COLUMN_SELECTOR = '[data-pane="sidebar"]'
24
-
25
22
  /** Inline icon (matches the shell's 16px nav-icon look): a terminal prompt glyph. */
26
23
  const ICON = '<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="2.5" width="12" height="11" rx="1.5"/><path d="M4.5 5.5l2.5 2.5-2.5 2.5"/><path d="M8.5 10.5h3"/></svg>'
27
24
 
28
25
  /** Find the sidebar shell root element, or undefined while not yet mounted. */
29
26
  function sidebarRoot(): HTMLElement | undefined {
30
- const column = document.querySelector<HTMLElement>(SIDEBAR_COLUMN_SELECTOR)
31
- const root = column?.firstElementChild as HTMLElement | undefined
32
- return root ?? undefined
27
+ const column = document.querySelector<HTMLElement>('[data-pane="sidebar"], [class*="sidebarCol"]')
28
+ if (column === null) return undefined
29
+ // Current shells wrap the sidebar UI: column > wrapper > root(logoRow owner).
30
+ // Prefer the element that owns the logo row — the real sidebar UI root —
31
+ // and fall back to the column's first child for legacy shells.
32
+ const logoOwner = column.querySelector<HTMLElement>('[class*="logoRow"]')?.parentElement
33
+ return logoOwner ?? (column.firstElementChild as HTMLElement | undefined)
33
34
  }
34
35
 
35
- /** The New Session button: the shell's only direct-child button of the root. */
36
+ /** The New Session button: nested in the logo row on current shells, a direct child on legacy shells. */
36
37
  function newSessionButton(root: HTMLElement): HTMLButtonElement | undefined {
38
+ const nested = root.querySelector<HTMLButtonElement>('button[class*="newSession"]')
39
+ if (nested !== null) return nested
37
40
  for (const child of root.children) {
38
41
  if (child.tagName === 'BUTTON') return child as HTMLButtonElement
39
42
  }
@@ -53,14 +56,26 @@ function createEntry(controller: PanelController): HTMLButtonElement {
53
56
  return entry
54
57
  }
55
58
 
56
- /** Re-insert the entry after the New Session button (before the browser region). */
59
+ /** Re-insert the entry after the New Session row (before the browser region). */
57
60
  function placeEntry(root: HTMLElement, entry: HTMLButtonElement): boolean {
58
61
  const button = newSessionButton(root)
59
62
  if (button === undefined) return false
60
63
  if (entry.parentElement !== root) {
61
- // Insert after the button: React never manages this node, and the shell
62
- // keeps its own child order intact around it.
63
- root.insertBefore(entry, button.nextElementSibling)
64
+ // Position relative to the family block (entries injected by sibling
65
+ // plugins), never relative to transient logoRow geometry: every family
66
+ // plugin that self-heals during a re-render then lands in the same
67
+ // relative order, so the entries cannot swap positions regardless of
68
+ // observer callback order or of shell wrapper changes. There is no
69
+ // append-to-end fallback: appending at the end would randomly reorder
70
+ // the block after a shell re-render.
71
+ const row = button.closest('[class*="logoRow"]')
72
+ const base = (row !== null && row.parentElement === root) ? row : button
73
+ const family = Array.from(root.children).filter(
74
+ (el): el is HTMLElement => el instanceof HTMLElement && el.matches('[data-dsh-taskboard-entry], [data-dsh-ssh-entry]'),
75
+ )
76
+ // ssh sits after the whole family block.
77
+ const anchor = family.length > 0 ? family[family.length - 1].nextElementSibling : base.nextElementSibling
78
+ root.insertBefore(entry, anchor)
64
79
  }
65
80
  return true
66
81
  }
@@ -77,19 +92,37 @@ export function mountSidebarEntry(controller: PanelController): () => void {
77
92
  let placed = false
78
93
 
79
94
  const tryPlace = (): void => {
80
- if (placed) return
81
95
  if (root !== undefined && !root.isConnected) {
82
- // The shell re-created the sidebar pane; re-query from scratch.
96
+ // The shell rebuilt the sidebar pane (whole-tree teardown); the root
97
+ // observer is gone with the old tree, so detach it and re-query from
98
+ // scratch. The new pane is later noticed by the body-level watcher.
83
99
  rootObserver.disconnect()
84
100
  root = undefined
101
+ placed = false
102
+ }
103
+ if (placed) {
104
+ // Cheap short-circuit: entry still lives in a mountable subtree.
105
+ if (document.body.contains(entry)) return
106
+ // Entry was torn down together with the old tree; reset and re-place.
107
+ rootObserver.disconnect()
108
+ root = undefined
109
+ placed = false
85
110
  }
86
111
  root ??= sidebarRoot()
87
112
  if (root === undefined) return
88
113
  placed = placeEntry(root, entry)
89
- if (placed) rootObserver.observe(root, { childList: true, subtree: true })
114
+ if (placed) {
115
+ rootObserver.observe(root, { childList: true, subtree: true })
116
+ }
90
117
  }
91
118
 
92
- // The shell renders after boot settlement; watch for its arrival.
119
+ // Body-level watcher retained as the "whole rebuild" fallback: when the shell
120
+ // tears down the whole sidebar pane, the root observer is gone with it and
121
+ // only this body observation can notice the new pane mounting. It is no
122
+ // longer disconnected after placement; the placed-and-still-mounted case
123
+ // short-circuits through the cheap document.body.contains(entry) check, so
124
+ // unrelated app mutations (e.g. chat streaming) cost one contains check
125
+ // instead of churning the full re-query.
93
126
  const waitObserver = new MutationObserver(() => { tryPlace() })
94
127
  waitObserver.observe(document.body, { childList: true, subtree: true })
95
128