diff --git a/README.md b/README.md index e444f07..65bed43 100644 --- a/README.md +++ b/README.md @@ -115,8 +115,10 @@ Linux CI 的桌面测试使用 `xvfb-run -a`。测试中的模拟更新适配器 空文件显示“新增空文件”,二进制文件和无法读取的文件有明确反馈。预览支持 UTF-8 文本,新增文件/暂存内容及差异输出限制为 1 MiB,差异补丁最多 10,000 行;超限时显示提示。预览不写入文件或暂存区。验证范围和结果见 [新增文件差异预览验收](https://github.com/ShaoClean/remote-git/wiki/Issue-18-Validation)。 -工作区侧栏与改动列表支持拖动调宽,也可从“布局设置”使用滑块调整或恢复默认。分隔线支持方向键、Shift 加速和 Home / End;`Cmd/Ctrl + \` 开关侧栏。布局偏好在本机保存,保留现有工作区树的展开与排序设置。 +工作区采用左侧仓库导航、中央 Diff、右侧改动与提交布局。顶栏按钮可分别隐藏或重新打开两侧面板,Diff 工具栏的“专注阅读差异”可一次隐藏两侧。左下角的应用菜单集中提供设置与帮助;左栏隐藏时,菜单移至顶栏。 + +两侧面板支持拖动调宽,也可在“设置 → 布局”调整显隐、宽度、默认 Diff 模式或恢复默认。分隔线支持方向键、Shift 加速和 Home / End;`Cmd/Ctrl+B` 切换左侧,`Cmd/Ctrl+Shift+B` 切换右侧,`Cmd/Ctrl+,` 打开设置,原有 `Cmd/Ctrl+\` 仍可切换左侧。布局偏好在本机保存,恢复默认不会清除工作区树的展开与排序设置。分栏 Diff 的长行自动折行。 窄窗口选择文件或提交后进入检查器,使用“返回列表”继续操作。提交摘要和描述在本次页面会话内按仓库保留;刷新页面会清空草稿。 -界面截图、验证结果和测试环境说明见 [工作区布局验收](https://github.com/ShaoClean/remote-git/wiki/Issue-6-Validation)。 +本次重构截图与验证结果见 [Issue #31 验收](https://github.com/ShaoClean/remote-git/wiki/Issue-31-Validation)。既有工作区布局记录见 [工作区布局验收](https://github.com/ShaoClean/remote-git/wiki/Issue-6-Validation)。 diff --git a/apps/desktop/src/ai-smoke.cjs b/apps/desktop/src/ai-smoke.cjs index 820052e..88f8099 100644 --- a/apps/desktop/src/ai-smoke.cjs +++ b/apps/desktop/src/ai-smoke.cjs @@ -91,7 +91,7 @@ module.exports = async ({ backend, origin, token, window, restore = false }) => }; check(); })`); try { - await execute(`document.querySelector('[aria-label="设置"]').click()`); + await execute(`window.dispatchEvent(new KeyboardEvent('keydown', { key: ',', ctrlKey: true }))`); window.setSize(390, 844); await wait( `document.querySelector('select[aria-label="设置分类"]')?.getClientRects().length && document.querySelector('.provider-list__item')`, diff --git a/apps/desktop/src/diff-smoke.cjs b/apps/desktop/src/diff-smoke.cjs index 11af7e8..2b8101e 100644 --- a/apps/desktop/src/diff-smoke.cjs +++ b/apps/desktop/src/diff-smoke.cjs @@ -92,9 +92,9 @@ module.exports = async ({ window, origin, token, backend }) => { await waitFor("document.querySelector('.file-row__select')"); await preview(file); await waitFor( - "document.querySelector('.diff-line--add')?.textContent.includes('desktop first')", + "document.querySelector('.diff-code-row--add')?.textContent.includes('desktop first')", ); - assert.equal(await execute("document.querySelectorAll('.diff-line--remove').length"), 0); + assert.equal(await execute("document.querySelectorAll('.diff-code-row--remove').length"), 0); await execute( "Array.from(document.querySelectorAll('.ant-segmented-item')).find(item => item.textContent === '分栏').click()", ); @@ -102,9 +102,9 @@ module.exports = async ({ window, origin, token, backend }) => { "document.querySelector('.diff-split-cell--add')?.textContent.includes('desktop first')", ); assert.equal(await execute("document.querySelectorAll('.diff-split-cell--remove').length"), 0); - await click('[aria-label="放大查看差异"]'); + await click('[aria-label="专注阅读差异"]'); await waitFor( - "document.querySelector('.diff-zoom-modal .diff-split-cell--add')?.textContent.includes('desktop first')", + "document.querySelector('.app-shell--collapsed') && document.querySelector('.workspace-body--right-hidden') && document.querySelector('.diff-split-cell--add')?.textContent.includes('desktop first')", ); if (process.env.REMOTE_GIT_DIFF_SMOKE_SCREENSHOT) { require('node:fs').writeFileSync( @@ -112,7 +112,8 @@ module.exports = async ({ window, origin, token, backend }) => { (await window.webContents.capturePage()).toPNG(), ); } - await click('.diff-zoom-modal .ant-modal-close'); + await click('[aria-label="显示左侧工作区"]'); + await click('[aria-label="显示右侧面板"]'); await click(`button[aria-label="暂存 ${file}"]`); await waitFor("document.querySelector('.diff-shell__title')?.textContent.includes('已暂存')"); edited = true; @@ -131,7 +132,7 @@ module.exports = async ({ window, origin, token, backend }) => { "document.querySelector('.diff-split-cell--add')?.textContent.includes('desktop second')", ); assert.equal( - await execute("document.querySelector('.diff-split-cell--remove')?.textContent"), + await execute("document.querySelector('.diff-split-cell--remove code')?.textContent"), 'desktop first', ); await click(`button[aria-label="取消暂存 ${file}"]`); @@ -172,7 +173,7 @@ module.exports = async ({ window, origin, token, backend }) => { await click('[aria-label="关闭差异"]'); await waitFor("!document.querySelector('.diff-shell')"); console.log( - 'Desktop new-file diff passed: unified, split, zoom, staging, editing, unstaging, feedback, late responses and closing.', + 'Desktop new-file diff passed: unified, split, focus, staging, editing, unstaging, feedback, late responses and closing.', ); } finally { connections.ensureConnected = originalConnect; diff --git a/apps/desktop/src/sidebar-smoke.cjs b/apps/desktop/src/sidebar-smoke.cjs index a7b692c..e1158eb 100644 --- a/apps/desktop/src/sidebar-smoke.cjs +++ b/apps/desktop/src/sidebar-smoke.cjs @@ -90,6 +90,8 @@ module.exports = async ({ window, origin, token, restore }) => { sidebarWidth: 310, changesWidth: 430, sidebarCollapsed: true, + changesCollapsed: false, + diffMode: 'unified', }); await waitFor("document.querySelector('.app-shell--collapsed') !== null"); } @@ -144,24 +146,32 @@ module.exports = async ({ window, origin, token, restore }) => { await execute( `document.querySelector('[aria-label="调整工作区宽度"]').dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true }))`, ); - await execute(`document.querySelector('[aria-label="布局设置"]').click()`); - await waitFor('document.querySelector(\'input[aria-label="改动列表宽度"]\') !== null'); + await execute('document.querySelector(\'[aria-label="设置与帮助"]\').click()'); + await waitFor('document.querySelector(\'[role="menu"]\') !== null'); + await execute('document.querySelector(\'[role="menuitem"]\').click()'); + await waitFor("document.querySelector('.settings-navigation') !== null"); + await execute( + "Array.from(document.querySelectorAll('.settings-navigation button')).find(button => button.textContent === '布局').click()", + ); + await waitFor('document.querySelector(\'input[aria-label="右侧面板宽度"]\') !== null'); await execute(`(() => { - const slider = document.querySelector('input[aria-label="改动列表宽度"]'); + const slider = document.querySelector('input[aria-label="右侧面板宽度"]'); Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set.call(slider, '430'); slider.dispatchEvent(new Event('input', { bubbles: true })); })()`); - await waitFor("document.querySelector('input[aria-label=\"改动列表宽度\"]').value === '430'"); + await waitFor("document.querySelector('input[aria-label=\"右侧面板宽度\"]').value === '430'"); await execute( `Array.from(document.querySelectorAll('.settings-header button')).find(button => button.textContent.replaceAll(' ', '') === '返回工作区').click()`, ); - await execute(`document.querySelector('[aria-label="收起导航"]').click()`); + await execute(`document.querySelector('[aria-label="隐藏左侧工作区"]').click()`); await waitFor("document.querySelector('.app-shell--collapsed') !== null"); const withLayout = JSON.parse(await execute('window.remoteGitWorkspace.load()')).state; assert.deepEqual(withLayout.layout, { sidebarWidth: 310, changesWidth: 430, sidebarCollapsed: true, + changesCollapsed: false, + diffMode: 'unified', }); assert.deepEqual(withLayout.repositoryOrderByConnection[a], expectedOrder); console.log('Desktop layout UI saved widths and collapse state without altering tree order.'); diff --git a/apps/desktop/src/smoke.cjs b/apps/desktop/src/smoke.cjs index d2d1837..85a8d45 100644 --- a/apps/desktop/src/smoke.cjs +++ b/apps/desktop/src/smoke.cjs @@ -34,7 +34,7 @@ module.exports = async ({ window, origin, token, updates, closeBackend, backend, }; check(); })`); - await waitForUI(window, `document.querySelector('.sidebar-footer__version')?.textContent === ${JSON.stringify(`v${version}`)}`); + await waitForUI(window, `document.querySelector('.workspace-menu-version')?.textContent === ${JSON.stringify(`v${version}`)}`); const renderer = await window.webContents.executeJavaScript(`(async () => ({ node: typeof process, require: typeof require, @@ -172,7 +172,7 @@ module.exports = async ({ window, origin, token, updates, closeBackend, backend, async function openUpdateSettings(window) { const current = await window.webContents.executeJavaScript("location.pathname"); if (current !== '/settings/updates') { - await window.webContents.executeJavaScript(`document.querySelector('[aria-label="设置"]').click()`); + await window.webContents.executeJavaScript(`window.dispatchEvent(new KeyboardEvent('keydown', { key: ',', ctrlKey: true }))`); await waitForUI(window, `document.querySelector('.settings-navigation')`); await window.webContents.executeJavaScript(`Array.from(document.querySelectorAll('.settings-navigation button')).find(button => button.textContent === '版本更新').click()`); } diff --git a/apps/web/src/components/DiffViewer.tsx b/apps/web/src/components/DiffViewer.tsx index 6c03e2b..f20133b 100644 --- a/apps/web/src/components/DiffViewer.tsx +++ b/apps/web/src/components/DiffViewer.tsx @@ -2,13 +2,17 @@ import { useEffect, useRef, useState } from 'react'; import { Button, Modal, Segmented } from 'antd'; import { CloseOutlined, DiffOutlined, ExpandOutlined } from '@ant-design/icons'; import ReactDiffViewer, { DiffMethod } from 'react-diff-viewer-continued'; -import { getDiffLines, getDiffNotice } from './diff-lines'; +import { getNumberedDiffLines, getDiffNotice } from './diff-lines'; +import type { NumberedDiffLine } from './diff-lines'; +import { useWorkspaceStore } from '../stores/workspaceStore'; interface Props { oldCode?: string; newCode?: string; diff?: string; title?: string; + subtitle?: string; + onFocus?: () => void; splitView?: boolean; onClose?: () => void; loading?: boolean; @@ -23,10 +27,12 @@ interface SplitDiffRow { right?: string; leftKind?: SplitCellKind; rightKind?: SplitCellKind; + oldLine?: number; + newLine?: number; } function getSplitDiffRows(diff: string): SplitDiffRow[] { - const lines = getDiffLines(diff); + const lines = getNumberedDiffLines(diff); const rows: SplitDiffRow[] = []; for (let index = 0; index < lines.length; ) { @@ -38,21 +44,23 @@ function getSplitDiffRows(diff: string): SplitDiffRow[] { } if (line.kind === 'remove') { - const removed: string[] = []; + const removed: NumberedDiffLine[] = []; while (index < lines.length && lines[index].kind === 'remove') { - removed.push(lines[index].text.slice(1)); + removed.push(lines[index]); index += 1; } - const added: string[] = []; + const added: NumberedDiffLine[] = []; while (index < lines.length && lines[index].kind === 'add') { - added.push(lines[index].text.slice(1)); + added.push(lines[index]); index += 1; } const rowCount = Math.max(removed.length, added.length); for (let row = 0; row < rowCount; row += 1) { rows.push({ - left: removed[row] || '', - right: added[row] || '', + left: removed[row]?.text.slice(1) || '', + right: added[row]?.text.slice(1) || '', + oldLine: removed[row]?.oldLine, + newLine: added[row]?.newLine, leftKind: removed[row] === undefined ? 'empty' : 'remove', rightKind: added[row] === undefined ? 'empty' : 'add', }); @@ -61,19 +69,32 @@ function getSplitDiffRows(diff: string): SplitDiffRow[] { } if (line.kind === 'add') { - const added: string[] = []; + const added: NumberedDiffLine[] = []; while (index < lines.length && lines[index].kind === 'add') { - added.push(lines[index].text.slice(1)); + added.push(lines[index]); index += 1; } added.forEach((value) => - rows.push({ left: '', right: value, leftKind: 'empty', rightKind: 'add' }), + rows.push({ + left: '', + right: value.text.slice(1), + newLine: value.newLine, + leftKind: 'empty', + rightKind: 'add', + }), ); continue; } const context = line.text.slice(1); - rows.push({ left: context, right: context, leftKind: 'context', rightKind: 'context' }); + rows.push({ + left: context, + right: context, + oldLine: line.oldLine, + newLine: line.newLine, + leftKind: 'context', + rightKind: 'context', + }); index += 1; } @@ -85,12 +106,20 @@ export function DiffViewer({ newCode = '', diff, title, - splitView = false, + subtitle, + onFocus, + splitView, onClose, loading = false, error, }: Props) { - const [mode, setMode] = useState<'unified' | 'split'>(splitView ? 'split' : 'unified'); + const preferredMode = useWorkspaceStore((state) => state.layout.diffMode); + const [mode, setMode] = useState<'unified' | 'split'>( + splitView === undefined ? preferredMode : splitView ? 'split' : 'unified', + ); + useEffect(() => { + setMode(splitView === undefined ? preferredMode : splitView ? 'split' : 'unified'); + }, [preferredMode, splitView]); const [zoomed, setZoomed] = useState(false); const bodyRef = useRef(null); const zoomBodyRef = useRef(null); @@ -102,24 +131,37 @@ export function DiffViewer({ }, [diff, error, loading, mode, title, zoomed]); const renderUnifiedDiff = (value: string) => { - const lines = getDiffLines(value); + const lines = getNumberedDiffLines(value); return ( -
-        {lines.map(({ text: line, kind }, index) => {
-          const className = kind === 'context' ? undefined : `diff-line--${kind}`;
+      
+ {lines.map(({ text: line, kind, oldLine, newLine }, index) => { + const className = `diff-code-row diff-code-row--${kind}`; return ( - - {line} - {index < lines.length - 1 ? '\n' : ''} - +
+ {kind !== 'meta' && ( + <> + + + + )} + {line} +
); })} -
+ ); }; const renderSplitDiff = (value: string) => (
+
+ 原版本 + 修改后 +
{getSplitDiffRows(value).map((row, index) => row.meta !== undefined ? (
@@ -127,8 +169,18 @@ export function DiffViewer({
) : (
- {row.left} - {row.right} + + + {row.left} + + + + {row.right} +
), )} @@ -175,7 +227,10 @@ export function DiffViewer({
- {title || '差异预览'} +
+ {title || '差异预览'} + {subtitle && {subtitle}} +
视图 @@ -192,13 +247,11 @@ export function DiffViewer({ type="text" size="small" icon={} - aria-label="放大查看差异" - title="放大查看差异" + aria-label={onFocus ? '专注阅读差异' : '放大查看差异'} + title={onFocus ? '专注阅读差异 · 隐藏两侧面板' : '放大查看差异'} disabled={loading || Boolean(error) || !hasDiff} - onClick={() => setZoomed(true)} - > - 放大 - + onClick={() => (onFocus ? onFocus() : setZoomed(true))} + /> {onClose && ( + 工作区 + {compact && ( + setMobileNavOpen(false)} + /> + )}
-
+ } + allowClear + value={repositoryQuery} + onChange={(event) => setRepositoryQuery(event.target.value)} + />
-
- 工作区 - - {connections.length + repositories.length} - -
-
-
- - - - - - - - - -
-
- -
- RemoteGit - 工作区就绪 -
- - {isDesktop - ? updateState - ? `v${updateState.currentVersion}` - : '…' - : `v${__APP_VERSION__}`} - -
+ {sidebarVisible && ( + { + setMobileNavOpen(false); + openSettings(); + }} + /> + )}
@@ -359,144 +393,111 @@ export function Layout() { )}
-
- {compact && ( -
{workspaceOutlet.current}
-
-
-
+
+
+ - - {activeRepository && ( - <> - - - {activeRepository.path || '仓库工作区'} - - - )} -
-
- - 上次刷新{' '} - {new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })} - -
-
- + + 已打开仓库 · {openRepositories.length} + + + + {activeRepository && ( + <> + + + {activeRepository.path || '仓库工作区'} + + + )} +
+
+ + 上次刷新{' '} + {new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })} + +
+
); diff --git a/apps/web/src/components/LayoutSettings.tsx b/apps/web/src/components/LayoutSettings.tsx index 4c7cc82..5e83b1a 100644 --- a/apps/web/src/components/LayoutSettings.tsx +++ b/apps/web/src/components/LayoutSettings.tsx @@ -1,4 +1,4 @@ -import { Button, Modal, Switch } from 'antd'; +import { Button, Modal, Segmented, Switch } from 'antd'; import { useWorkspaceStore } from '../stores/workspaceStore'; import { CHANGES_MAX, CHANGES_MIN, SIDEBAR_MAX, SIDEBAR_MIN } from '../stores/workspaceLayout'; @@ -20,37 +20,73 @@ export function LayoutSettingsContent() { const { layout, updateLayout, resetLayout } = useWorkspaceStore(); return (
-

窗口较小时自动适配;放大后恢复已保存的宽度。

- - -
- 收起工作区 - updateLayout({ sidebarCollapsed })} - /> -
+
+

侧边面板

+

独立显示或隐藏两侧面板,为差异阅读留出空间。

+
+ + 显示左侧工作区连接与仓库 · ⌘ / Ctrl B + + updateLayout({ sidebarCollapsed: !visible })} + /> +
+
+ + 显示右侧面板改动、提交与历史 · ⌘ / Ctrl ⇧ B + + updateLayout({ changesCollapsed: !visible })} + /> +
+
+
+

面板宽度

+

窗口较小时自动适配;放大后恢复已保存的宽度。

+ + +
+
+

差异视图

+
+ + 默认 Diff 模式也可在差异工具栏随时切换 + + updateLayout({ diffMode: diffMode as 'unified' | 'split' })} + /> +
+

布局在此设备保存,恢复默认布局会保留工作区树的展开与排序设置。

diff --git a/apps/web/src/components/PanelResizeHandle.tsx b/apps/web/src/components/PanelResizeHandle.tsx index cb87149..12fabd4 100644 --- a/apps/web/src/components/PanelResizeHandle.tsx +++ b/apps/web/src/components/PanelResizeHandle.tsx @@ -9,6 +9,7 @@ interface Props { max: number; onChange: (width: number) => void; className?: string; + side?: 'left' | 'right'; } export function PanelResizeHandle({ @@ -19,6 +20,7 @@ export function PanelResizeHandle({ max, onChange, className = '', + side = 'left', }: Props) { const start = useRef<{ x: number; width: number } | null>(null); const [dragging, setDragging] = useState(false); @@ -45,7 +47,14 @@ export function PanelResizeHandle({ }} onPointerMove={(event) => { if (start.current && event.currentTarget.hasPointerCapture(event.pointerId)) { - onChange(clampWidth(start.current.width + event.clientX - start.current.x, min, max)); + const direction = side === 'right' ? -1 : 1; + onChange( + clampWidth( + start.current.width + (event.clientX - start.current.x) * direction, + min, + max, + ), + ); } }} onPointerUp={(event) => { @@ -58,15 +67,16 @@ export function PanelResizeHandle({ }} onKeyDown={(event) => { const step = event.shiftKey ? 40 : 10; + const direction = side === 'right' ? -1 : 1; const next = event.key === 'Home' ? min : event.key === 'End' ? max : event.key === 'ArrowLeft' - ? value - step + ? value - step * direction : event.key === 'ArrowRight' - ? value + step + ? value + step * direction : undefined; if (next === undefined) return; event.preventDefault(); diff --git a/apps/web/src/components/PanelToggle.tsx b/apps/web/src/components/PanelToggle.tsx new file mode 100644 index 0000000..562fe05 --- /dev/null +++ b/apps/web/src/components/PanelToggle.tsx @@ -0,0 +1,47 @@ +interface Props { + side: 'left' | 'right'; + expanded: boolean; + controls: string; + onClick: () => void; + disabled?: boolean; +} + +export function PanelToggle({ side, expanded, controls, onClick, disabled = false }: Props) { + const label = `${expanded ? '隐藏' : '显示'}${side === 'left' ? '左侧工作区' : '右侧面板'}`; + return ( + + ); +} diff --git a/apps/web/src/components/WorkspaceMenu.tsx b/apps/web/src/components/WorkspaceMenu.tsx new file mode 100644 index 0000000..d96dc6e --- /dev/null +++ b/apps/web/src/components/WorkspaceMenu.tsx @@ -0,0 +1,91 @@ +import { useRef, useState } from 'react'; +import { Dropdown } from 'antd'; +import { QuestionCircleOutlined, SettingOutlined, UpOutlined } from '@ant-design/icons'; +import { BrandIcon } from './BrandIcon'; + +export function WorkspaceMenu({ + compact = false, + version, + onSettings, +}: { + compact?: boolean; + version: string; + onSettings: () => void; +}) { + const [open, setOpen] = useState(false); + const trigger = useRef(null); + const close = () => { + setOpen(false); + trigger.current?.focus(); + }; + return ( + , + label: ( + + 设置⌘ / Ctrl , + + ), + onClick: onSettings, + }, + { type: 'divider' }, + { + key: 'help', + icon: , + label: ( + + 帮助与文档 + + ), + }, + ], + onClick: close, + onKeyDown: (event) => { + if (event.key === 'Escape') { + event.stopPropagation(); + close(); + } + }, + }} + > + + + ); +} diff --git a/apps/web/src/components/WorkspaceTree.tsx b/apps/web/src/components/WorkspaceTree.tsx index 25f5362..a85af04 100644 --- a/apps/web/src/components/WorkspaceTree.tsx +++ b/apps/web/src/components/WorkspaceTree.tsx @@ -1,6 +1,12 @@ import { useEffect, useRef, useState } from 'react'; import type { DragEvent, KeyboardEvent } from 'react'; -import { BranchesOutlined, DownOutlined, FolderOpenOutlined, HolderOutlined, RightOutlined } from '@ant-design/icons'; +import { + BranchesOutlined, + DownOutlined, + FolderOpenOutlined, + HolderOutlined, + RightOutlined, +} from '@ant-design/icons'; import type { Repository } from '@remote-git/shared'; import { useWorkspaceStore } from '../stores/workspaceStore'; import { RepositoryStatusIndicator } from './RepositoryStatusIndicator'; @@ -8,20 +14,37 @@ import { useRepositoryStore } from '../stores/repositoryStore'; import { canMoveTreeItem, orderItems } from '../stores/sidebarOrder'; import type { Placement, TreeItem } from '../stores/sidebarOrder'; -interface Connection { id: string; name: string; status?: string } +interface Connection { + id: string; + name: string; + status?: string; +} interface Props { connections: Connection[]; repositories: Repository[]; testResults: Record; activeId?: string; + query?: string; onOpenRepository: (repo: Repository) => void; } type DropTarget = { item: TreeItem; placement: Placement }; -export function WorkspaceTree({ connections, repositories, testResults, activeId, onOpenRepository }: Props) { +export function WorkspaceTree({ + connections, + repositories, + testResults, + activeId, + query = '', + onOpenRepository, +}: Props) { const { - treeOpen, setTreeOpen, collapsedConnectionIds, setConnectionCollapsed, - connectionOrder, repositoryOrderByConnection, moveTreeItem, + treeOpen, + setTreeOpen, + collapsedConnectionIds, + setConnectionCollapsed, + connectionOrder, + repositoryOrderByConnection, + moveTreeItem, } = useWorkspaceStore(); const { listLoaded, listError, fetchRepositories } = useRepositoryStore(); const [dragging, setDragging] = useState(null); @@ -33,10 +56,21 @@ export function WorkspaceTree({ connections, repositories, testResults, activeId const scrollFrame = useRef(null); const pointerY = useRef(null); - const groups = orderItems(connections, connectionOrder).map((connection) => ({ - ...connection, - repositories: orderItems(repositories.filter((repo) => repo.connectionId === connection.id), repositoryOrderByConnection[connection.id]), - })); + const search = query.trim().toLowerCase(); + const groups = orderItems(connections, connectionOrder) + .map((connection) => ({ + ...connection, + repositories: orderItems( + repositories.filter( + (repo) => + repo.connectionId === connection.id && + (!search || + `${connection.name} ${repo.name} ${repo.path}`.toLowerCase().includes(search)), + ), + repositoryOrderByConnection[connection.id], + ), + })) + .filter((connection) => !search || connection.repositories.length > 0); const stopScroll = () => { if (scrollFrame.current !== null) cancelAnimationFrame(scrollFrame.current); @@ -56,7 +90,10 @@ export function WorkspaceTree({ connections, repositories, testResults, activeId if (event.key === 'Escape') finishDrag(); }; document.addEventListener('keydown', cancel); - return () => { document.removeEventListener('keydown', cancel); stopScroll(); }; + return () => { + document.removeEventListener('keydown', cancel); + stopScroll(); + }; }, []); const autoScroll = (event: DragEvent) => { @@ -65,10 +102,17 @@ export function WorkspaceTree({ connections, repositories, testResults, activeId if (scrollFrame.current !== null) return; const tick = () => { const container = tree.current?.closest('.app-sidebar__content'); - if (!container || pointerY.current === null) { stopScroll(); return; } + if (!container || pointerY.current === null) { + stopScroll(); + return; + } const { top, bottom } = container.getBoundingClientRect(); - const distance = pointerY.current - top < 36 ? pointerY.current - top - 36 - : bottom - pointerY.current < 36 ? 36 - (bottom - pointerY.current) : 0; + const distance = + pointerY.current - top < 36 + ? pointerY.current - top - 36 + : bottom - pointerY.current < 36 + ? 36 - (bottom - pointerY.current) + : 0; container.scrollTop += Math.max(-14, Math.min(14, distance / 2)); scrollFrame.current = requestAnimationFrame(tick); }; @@ -81,7 +125,8 @@ export function WorkspaceTree({ connections, repositories, testResults, activeId setDragging(item); event.dataTransfer.effectAllowed = 'move'; event.dataTransfer.setData('application/x-remote-git-tree', JSON.stringify(item)); - if (event.currentTarget.parentElement) event.dataTransfer.setDragImage(event.currentTarget.parentElement, 15, 15); + if (event.currentTarget.parentElement) + event.dataTransfer.setDragImage(event.currentTarget.parentElement, 15, 15); }; const position = (event: DragEvent): Placement => { @@ -102,8 +147,13 @@ export function WorkspaceTree({ connections, repositories, testResults, activeId event.preventDefault(); event.dataTransfer.dropEffect = 'move'; const placement = position(event); - setDropTarget((current) => current?.item.kind === item.kind && current.item.id === item.id && current.placement === placement - ? current : { item, placement }); + setDropTarget((current) => + current?.item.kind === item.kind && + current.item.id === item.id && + current.placement === placement + ? current + : { item, placement }, + ); }; const drop = (event: DragEvent, item: TreeItem) => { @@ -115,7 +165,12 @@ export function WorkspaceTree({ connections, repositories, testResults, activeId finishDrag(); }; - const keyboardMove = (event: KeyboardEvent, item: TreeItem, siblings: { id: string }[], name: string) => { + const keyboardMove = ( + event: KeyboardEvent, + item: TreeItem, + siblings: { id: string }[], + name: string, + ) => { if (!event.altKey || !['ArrowUp', 'ArrowDown'].includes(event.key)) return; event.preventDefault(); event.stopPropagation(); @@ -137,66 +192,141 @@ export function WorkspaceTree({ connections, repositories, testResults, activeId aria-label={`调整${item.kind === 'connection' ? '连接' : '仓库'} ${name} 的顺序`} aria-keyshortcuts="Alt+ArrowUp Alt+ArrowDown" title="拖动排序,或按 Alt + ↑ / ↓" - disabled={siblings.length < 2} - draggable={siblings.length > 1} + disabled={Boolean(search) || siblings.length < 2} + draggable={!search && siblings.length > 1} onDragStart={(event) => startDrag(event, item)} onDragEnd={finishDrag} onClick={(event) => event.stopPropagation()} onKeyDown={(event) => keyboardMove(event, item, siblings, name)} - > + > + + ); - const dropClass = (item: TreeItem) => dropTarget?.item.kind === item.kind && dropTarget.item.id === item.id - ? ` tree-drop--${dropTarget.placement}` : ''; - const draggingClass = (item: TreeItem) => dragging?.kind === item.kind && dragging.id === item.id ? ' tree-item--dragging' : ''; + const dropClass = (item: TreeItem) => + dropTarget?.item.kind === item.kind && dropTarget.item.id === item.id + ? ` tree-drop--${dropTarget.placement}` + : ''; + const draggingClass = (item: TreeItem) => + dragging?.kind === item.kind && dragging.id === item.id ? ' tree-item--dragging' : ''; return ( <> - - {treeOpen && ( + {(treeOpen || search) && (
{ - if (!event.currentTarget.contains(event.relatedTarget as Node | null)) { setDropTarget(null); stopScroll(); } + if (!event.currentTarget.contains(event.relatedTarget as Node | null)) { + setDropTarget(null); + stopScroll(); + } }} onClickCapture={(event) => { - if (dragSource.current || Date.now() < suppressClickUntil.current) { event.preventDefault(); event.stopPropagation(); } + if (dragSource.current || Date.now() < suppressClickUntil.current) { + event.preventDefault(); + event.stopPropagation(); + } }} > - {listError ?
仓库列表加载失败
- : !listLoaded &&
正在加载已登记仓库…
} - {groups.length === 0 &&
暂无连接
} + {listError ? ( +
+ 仓库列表加载失败{' '} + +
+ ) : ( + !listLoaded && ( +
+ 正在加载已登记仓库… +
+ ) + )} + {groups.length === 0 && ( +
{search ? '没有匹配的仓库' : '暂无连接'}
+ )} {groups.map((connection) => { const item: TreeItem = { kind: 'connection', id: connection.id }; - const open = !collapsedConnectionIds.includes(connection.id); + const open = Boolean(search) || !collapsedConnectionIds.includes(connection.id); const childrenId = `connection-repositories-${connection.id}`; return ( -
dragOver(event, item)} onDrop={(event) => drop(event, item)}> +
dragOver(event, item)} + onDrop={(event) => drop(event, item)} + >
{sortHandle(item, groups, connection.name)} -