import { describe, expect, it } from 'vitest' import type { FolderNode, HostNode, TreeNode } from '../src/shared/ipc-schemas' import { allFolderKeys, ancestorKeysOf, buildSidebarRows, computeMove, computeTabMove, dropPositionFor, folderKey, hostKey, LOCAL_KEY, orderAllSessions, nextAutoIndex, orderSessions, seqAfterSession, seqForNewSession, sessionKey, sessionLabel, type SidebarRow, type SidebarSession, } from '../src/renderer/src/lib/sidebar' // ---------------------------------------------------------------- fixtures function folder(id: number, name: string, children: TreeNode[] = []): FolderNode { return { kind: 'folder', id, parentId: null, name, sortOrder: id * 1024, children } } function host(id: number, label: string): HostNode { return { kind: 'host', id, parentId: null, label, hostname: '10.0.1.5', port: 22, username: 'root', authType: 'password', color: null, sortOrder: id * 1024, } } function session( id: string, hostId: number | null, seq: number, pinned = false, autoIndex = 1, ): SidebarSession { return { id, hostId, label: 'session', autoIndex, customLabel: null, status: 'connected', pinned, seq, host: null, } } function rowOf(rows: SidebarRow[], key: string): SidebarRow { const row = rows.find((r) => r.key === key) if (!row) throw new Error(`no row for ${key}`) return row } // ---------------------------------------------------------------- ordering describe('orderSessions', () => { it('puts pinned sessions first, then open order', () => { const ordered = orderSessions([ session('c', 1, 3), session('a', 1, 1), session('b', 1, 2, true), ]) expect(ordered.map((s) => s.id)).toEqual(['b', 'a', 'c']) }) it('does not mutate the input', () => { const input = [session('b', 1, 2), session('a', 1, 1)] orderSessions(input) expect(input.map((s) => s.id)).toEqual(['b', 'a']) }) }) describe('orderAllSessions', () => { it('puts local shells first, then one contiguous block per host', () => { // A session opened for host 2 after host 1 must not land between host 1's // sessions — same-host tabs stay together, which is the whole point of the // grouping. const ordered = orderAllSessions([ session('a', 1, 1), session('b', 2, 2), session('c', 1, 3), session('local', null, 4), ]) expect(ordered.map((s) => s.id)).toEqual(['local', 'a', 'c', 'b']) }) it('keeps hosts in the order they were first opened', () => { const ordered = orderAllSessions([session('a', 7, 1), session('b', 3, 2), session('c', 7, 3)]) expect(ordered.map((s) => s.id)).toEqual(['a', 'c', 'b']) }) it('applies pinning within each host group, not across groups', () => { const ordered = orderAllSessions([ session('a', 1, 1), session('b', 2, 2), session('c', 2, 3, true), ]) // c moves to the front of host 2's block, but does not jump above host 1. expect(ordered.map((s) => s.id)).toEqual(['a', 'c', 'b']) }) }) // ---------------------------------------------------------------- numbering describe('nextAutoIndex', () => { it('starts at 1 for a host with no sessions', () => { expect(nextAutoIndex([], 7)).toBe(1) }) it('counts per host, not globally', () => { expect(nextAutoIndex([session('a', 7, 1, false, 5)], 8)).toBe(1) }) it('treats local shells as their own group', () => { expect(nextAutoIndex([session('a', 7, 1, false, 3)], null)).toBe(1) expect(nextAutoIndex([session('a', null, 1, false, 2)], null)).toBe(3) }) it('increments past the highest index in use, not past the count', () => { // Three opened, the middle one closed: the next must not collide with the // surviving "3". Counting open sessions would hand out 3 a second time. const sessions = [session('a', 7, 1, false, 1), session('c', 7, 3, false, 3)] expect(nextAutoIndex(sessions, 7)).toBe(4) }) it('restarts at 1 once a host has no sessions left', () => { expect(nextAutoIndex([session('a', 9, 1, false, 4)], 7)).toBe(1) }) }) describe('sessionLabel', () => { it('leaves the first session for a host unnumbered', () => { expect(sessionLabel(session('a', 7, 1, false, 1), 'local')).toBe('session') }) it('appends the index from the second onward', () => { expect(sessionLabel(session('a', 7, 1, false, 2), 'local')).toBe('session 2') expect(sessionLabel(session('a', 7, 1, false, 11), 'local')).toBe('session 11') }) it('uses the translated base for a local shell', () => { // The base has to come from the caller: this module has no business // knowing a language. expect(sessionLabel(session('a', null, 1, false, 1), '本地终端')).toBe('本地终端') expect(sessionLabel(session('a', null, 1, false, 3), '本地终端')).toBe('本地终端 3') }) it('lets a custom name override the numbering entirely', () => { const renamed = { ...session('a', 7, 1, false, 4), customLabel: 'prod db' } expect(sessionLabel(renamed, 'local')).toBe('prod db') }) }) // ---------------------------------------------------------------- tab placement describe('seqForNewSession', () => { it('starts a host group at 1', () => { expect(seqForNewSession([], 7)).toBe(1) }) it('appends past the maximum, not past the array length', () => { // `seq` is fractional once anything has been dragged, so counting entries // would collide with an existing position. const sessions = [session('a', 7, 1), session('b', 7, 1.5), session('c', 7, 9)] expect(seqForNewSession(sessions, 7)).toBe(10) }) it('ignores other hosts', () => { expect(seqForNewSession([session('a', 7, 100)], 8)).toBe(1) }) }) describe('seqAfterSession', () => { it('lands between the original and the next one in its bucket', () => { const sessions = [session('a', 7, 1), session('b', 7, 3)] expect(seqAfterSession(sessions, 'a')).toBe(2) }) it('lands just past the original when it is last', () => { const sessions = [session('a', 7, 1), session('b', 7, 3)] expect(seqAfterSession(sessions, 'b')).toBe(4) }) it('does not see past its own pin bucket', () => { // A pinned neighbour is not "next" for an unpinned original. const sessions = [session('a', 7, 1), session('b', 7, 2, true), session('c', 7, 3)] expect(seqAfterSession(sessions, 'a')).toBe(2) }) }) describe('computeTabMove', () => { const sessions = [session('a', 7, 1), session('b', 7, 2), session('c', 7, 3)] it('reorders within a host group', () => { expect(computeTabMove(sessions, 'c', 'a', 'before')).toEqual({ seq: 0 }) expect(computeTabMove(sessions, 'a', 'c', 'after')).toEqual({ seq: 4 }) expect(computeTabMove(sessions, 'a', 'b', 'after')).toEqual({ seq: 2.5 }) }) it('refuses to move a tab into another host group', () => { // A session that left its host's block would have no place in a sidebar // that groups by host. const mixed = [...sessions, session('d', 9, 1)] expect(computeTabMove(mixed, 'a', 'd', 'before')).toBeNull() }) it('refuses to move across pin states', () => { // The sort would put it straight back where it started, so the drop is // better refused than silently undone. const mixed = [...sessions, session('p', 7, 4, true)] expect(computeTabMove(mixed, 'a', 'p', 'before')).toBeNull() }) it('refuses a drop onto itself', () => { expect(computeTabMove(sessions, 'a', 'a', 'before')).toBeNull() }) it('refuses landing back in the slot it came from', () => { expect(computeTabMove(sessions, 'a', 'b', 'before')).toBeNull() expect(computeTabMove(sessions, 'b', 'a', 'after')).toBeNull() }) }) // ---------------------------------------------------------------- rows describe('buildSidebarRows', () => { const tree = [folder(1, 'prod', [host(10, 'web'), host(11, 'db')]), host(12, 'rooted')] it('always shows the local entry, even with nothing open', () => { const rows = buildSidebarRows(tree, [], new Set()) expect(rows[0].key).toBe(LOCAL_KEY) expect(rows[0].kind).toBe('local') expect(rows[0].sessionCount).toBe(0) // Nothing under it to expand. expect(rows[0].expandable).toBe(false) }) it('omits children of collapsed folders', () => { const rows = buildSidebarRows(tree, [], new Set()) expect(rows.map((r) => r.key)).toEqual([LOCAL_KEY, folderKey(1), hostKey(12)]) }) it('includes children of expanded folders', () => { const rows = buildSidebarRows(tree, [], new Set([folderKey(1)])) expect(rows.map((r) => r.key)).toEqual([ LOCAL_KEY, folderKey(1), hostKey(10), hostKey(11), hostKey(12), ]) }) it('nests sessions under their host, once that host is expanded', () => { const sessions = [session('s1', 10, 1), session('s2', 10, 2)] const collapsed = buildSidebarRows(tree, sessions, new Set([folderKey(1)])) expect(collapsed.map((r) => r.key)).not.toContain(sessionKey('s1')) expect(rowOf(collapsed, hostKey(10)).sessionCount).toBe(2) expect(rowOf(collapsed, hostKey(10)).expandable).toBe(true) const expanded = buildSidebarRows(tree, sessions, new Set([folderKey(1), hostKey(10)])) expect(expanded.map((r) => r.key)).toEqual([ LOCAL_KEY, folderKey(1), hostKey(10), sessionKey('s1'), sessionKey('s2'), hostKey(11), hostKey(12), ]) }) it('nests local shells under the local entry', () => { const sessions = [session('l1', null, 1), session('l2', null, 2)] const rows = buildSidebarRows(tree, sessions, new Set([LOCAL_KEY])) expect(rows.map((r) => r.key)).toEqual([ LOCAL_KEY, sessionKey('l1'), sessionKey('l2'), folderKey(1), hostKey(12), ]) expect(rows[0].sessionCount).toBe(2) }) it('orders sessions within a host by pin state, not by array position', () => { const sessions = [session('a', 10, 1), session('b', 10, 2), session('c', 10, 3, true)] const rows = buildSidebarRows(tree, sessions, new Set([folderKey(1), hostKey(10)])) const sessionRows = rows.filter((r) => r.kind === 'session').map((r) => r.session?.id) expect(sessionRows).toEqual(['c', 'a', 'b']) }) it('indents sessions one level below their host', () => { const rows = buildSidebarRows( tree, [session('s1', 10, 1), session('l1', null, 2)], new Set([folderKey(1), hostKey(10), LOCAL_KEY]), ) expect(rowOf(rows, LOCAL_KEY).depth).toBe(0) expect(rowOf(rows, sessionKey('l1')).depth).toBe(1) expect(rowOf(rows, folderKey(1)).depth).toBe(0) expect(rowOf(rows, hostKey(10)).depth).toBe(1) expect(rowOf(rows, sessionKey('s1')).depth).toBe(2) }) it('makes a host a leaf again once its last session closes', () => { const withSession = buildSidebarRows(tree, [session('s1', 10, 1)], new Set([folderKey(1)])) expect(rowOf(withSession, hostKey(10)).expandable).toBe(true) const without = buildSidebarRows(tree, [], new Set([folderKey(1)])) expect(rowOf(without, hostKey(10)).expandable).toBe(false) }) it('carries the sibling layer a drop needs', () => { const rows = buildSidebarRows(tree, [], new Set([folderKey(1)])) expect(rowOf(rows, hostKey(10)).siblings?.map((n) => n.id)).toEqual([10, 11]) expect(rowOf(rows, hostKey(10)).parentId).toBe(1) expect(rowOf(rows, hostKey(12)).parentId).toBeNull() }) }) // ---------------------------------------------------------------- drop position describe('dropPositionFor', () => { const rect = { top: 100, height: 24 } it('offers three zones on a folder, the middle meaning "nest inside"', () => { expect(dropPositionFor(rect, 102, folder(1, 'f'))).toBe('before') expect(dropPositionFor(rect, 112, folder(1, 'f'))).toBe('inside') expect(dropPositionFor(rect, 122, folder(1, 'f'))).toBe('after') }) it('never offers "inside" on a host, which holds sessions but not nodes', () => { expect(dropPositionFor(rect, 102, host(1, 'h'))).toBe('before') expect(dropPositionFor(rect, 112, host(1, 'h'))).toBe('after') }) }) // ---------------------------------------------------------------- computeMove describe('computeMove', () => { const tree = [ folder(1, 'prod', [host(10, 'web'), host(11, 'db'), folder(3, 'inner', [host(13, 'deep')])]), folder(2, 'staging'), host(12, 'rooted'), ] const rows = buildSidebarRows(tree, [session('s1', 10, 1)], new Set([folderKey(1), hostKey(10)])) it('expresses dropping between two nodes', () => { expect(computeMove(host(12, 'rooted'), { row: rowOf(rows, hostKey(11)), position: 'before' })) .toEqual({ id: 12, parentId: 1, prevSiblingId: 10, nextSiblingId: 11 }) }) it('expresses dropping between two folders', () => { expect(computeMove(host(12, 'rooted'), { row: rowOf(rows, folderKey(2)), position: 'before' })) .toEqual({ id: 12, parentId: null, prevSiblingId: 1, nextSiblingId: 2 }) }) it('expresses dropping into a folder, appended after its children', () => { expect(computeMove(host(12, 'rooted'), { row: rowOf(rows, folderKey(2)), position: 'inside' })) .toEqual({ id: 12, parentId: 2, prevSiblingId: null, nextSiblingId: null }) }) it('refuses to drop a node on itself', () => { expect(computeMove(host(12, 'rooted'), { row: rowOf(rows, hostKey(12)), position: 'before' })) .toBeNull() }) it('refuses to drop a folder into its own descendant', () => { // Without this the subtree detaches from every root and disappears from the // UI while its rows remain. The main process guards it too; catching it // here means the drag simply refuses rather than erroring after the fact. // // The dragged node has to be the one from the tree — the check walks its // children, and a freshly built FolderNode with none has no subtree to // protect. const prod = tree[0] expect(computeMove(prod, { row: rowOf(rows, folderKey(3)), position: 'inside' })).toBeNull() }) it('allows dropping a folder into an unrelated folder', () => { expect(computeMove(folder(3, 'inner'), { row: rowOf(rows, folderKey(2)), position: 'inside' })) .toMatchObject({ id: 3, parentId: 2 }) }) it('refuses a session row as a drop target', () => { // Sessions are ordered by their own rules and are not in the database at // all, so there is no move to express. expect(computeMove(host(12, 'rooted'), { row: rowOf(rows, sessionKey('s1')), position: 'before' })) .toBeNull() }) it('treats landing back in the current slot as a no-op', () => { expect(computeMove(host(10, 'web'), { row: rowOf(rows, hostKey(11)), position: 'before' })) .toBeNull() }) }) // ---------------------------------------------------------------- helpers describe('ancestorKeysOf', () => { const tree = [folder(1, 'a', [folder(2, 'b', [host(10, 'deep')])])] it('lists the folders above a node, outermost first', () => { expect(ancestorKeysOf(tree, hostKey(10))).toEqual([folderKey(1), folderKey(2)]) }) it('returns nothing for a root-level node', () => { expect(ancestorKeysOf(tree, folderKey(1))).toEqual([]) }) }) describe('allFolderKeys', () => { it('collects folder keys only, so expand-all does not open every session list', () => { const tree = [folder(1, 'a', [folder(2, 'b'), host(10, 'x')]), host(11, 'y')] expect(allFolderKeys(tree).sort()).toEqual([folderKey(1), folderKey(2)]) }) })