@linxin666/dsh-ssh 0.1.2 → 0.1.3

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.3",
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
  }
@@ -86,7 +101,13 @@ export function mountSidebarEntry(controller: PanelController): () => void {
86
101
  root ??= sidebarRoot()
87
102
  if (root === undefined) return
88
103
  placed = placeEntry(root, entry)
89
- if (placed) rootObserver.observe(root, { childList: true, subtree: true })
104
+ if (placed) {
105
+ rootObserver.observe(root, { childList: true, subtree: true })
106
+ // Placement done; the root observer alone keeps the entry healed.
107
+ // Disconnect the body-wide watcher so unrelated app mutations (e.g.
108
+ // chat streaming) no longer churn every plugin's self-heal loop.
109
+ waitObserver.disconnect()
110
+ }
90
111
  }
91
112
 
92
113
  // The shell renders after boot settlement; watch for its arrival.