diff --git a/apps/desktop/electron/git-worktree-ops.cjs b/apps/desktop/electron/git-worktree-ops.cjs index 98373d90563..486686e4e4a 100644 --- a/apps/desktop/electron/git-worktree-ops.cjs +++ b/apps/desktop/electron/git-worktree-ops.cjs @@ -111,6 +111,41 @@ function slugify(name) { return slug || 'work' } +const TRUNK_BRANCHES = ['main', 'master'] + +async function gitLine(gitBin, args, cwd) { + try { + return (await runGit(gitBin, args, cwd)).trim() + } catch { + return '' + } +} + +async function defaultBranch(gitBin, cwd) { + const remote = (await gitLine(gitBin, ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'], cwd)).replace( + /^origin\//, + '' + ) + + if (remote) { + return remote + } + + const configured = await gitLine(gitBin, ['config', '--get', 'init.defaultBranch'], cwd) + + if (configured) { + return configured + } + + for (const branch of TRUNK_BRANCHES) { + if (await gitLine(gitBin, ['show-ref', '--verify', `refs/heads/${branch}`], cwd)) { + return branch + } + } + + return '' +} + // A brand-new project folder isn't a git repo — and a freshly-init'd one has no // commit to branch from — so `git worktree add` would fail. Make the dir a repo // with a root commit on the user's behalf so worktrees "just work". No-op for a @@ -169,6 +204,25 @@ function uniqueDir(base) { return dir } +async function addExistingBranchWorktree(gitBin, root, name) { + const branch = sanitizeBranch(name) + + if (!branch) { + throw new Error('Branch name is required.') + } + + if (branch === (await defaultBranch(gitBin, root))) { + await runGit(gitBin, ['switch', branch], root) + + return { path: root, branch, repoRoot: root } + } + + const dir = uniqueDir(path.join(root, '.worktrees', slugify(branch))) + await runGit(gitBin, ['worktree', 'add', dir, branch], root) + + return { path: dir, branch, repoRoot: root } +} + async function addWorktree(repoPath, options, gitBin) { const resolved = resolveRequestedPathForIpc(repoPath, { purpose: 'Worktree add' }) // A new project's folder may not be a git repo yet — init it (with a root @@ -177,20 +231,8 @@ async function addWorktree(repoPath, options, gitBin) { const root = await mainRoot(gitBin, resolved) const opts = options || {} - // "Convert an existing branch into a worktree": check the branch out into a - // fresh worktree dir as-is (no `-b`, no new branch). Dir is named off the - // branch slug so it reads like the branch it carries. if (opts.existingBranch) { - const existing = sanitizeBranch(opts.existingBranch) - - if (!existing) { - throw new Error('Branch name is required.') - } - - const dir = uniqueDir(path.join(root, '.worktrees', slugify(existing))) - await runGit(gitBin, ['worktree', 'add', dir, existing], root) - - return { path: dir, branch: existing, repoRoot: root } + return addExistingBranchWorktree(gitBin, root, opts.existingBranch) } const slug = slugify(opts.name || `work-${Date.now().toString(36)}`) @@ -255,12 +297,18 @@ async function listBranches(repoPath, gitBin) { ) const trees = await listWorktrees(resolved, gitBin) const pathByBranch = new Map(trees.filter(tree => tree.branch).map(tree => [tree.branch, tree.path])) + const trunk = await defaultBranch(gitBin, resolved) return out .split('\n') .map(line => line.trim()) .filter(Boolean) - .map(name => ({ name, checkedOut: pathByBranch.has(name), worktreePath: pathByBranch.get(name) || null })) + .map(name => ({ + name, + checkedOut: pathByBranch.has(name), + isDefault: Boolean(trunk && name === trunk), + worktreePath: pathByBranch.get(name) || null + })) } catch { return [] } diff --git a/apps/desktop/electron/git-worktree-ops.test.cjs b/apps/desktop/electron/git-worktree-ops.test.cjs index ec6a96c9621..b0865d4ad77 100644 --- a/apps/desktop/electron/git-worktree-ops.test.cjs +++ b/apps/desktop/electron/git-worktree-ops.test.cjs @@ -108,14 +108,36 @@ test('listBranches: lists locals and flags the checked-out branch', async () => assert.deepEqual(names, [current, 'feature'].sort()) // The repo's own checkout is flagged; the unused branch is convertible. assert.equal(branches.find(b => b.name === current).checkedOut, true) + assert.equal(branches.find(b => b.name === current).isDefault, true) assert.equal(fs.realpathSync(branches.find(b => b.name === current).worktreePath), fs.realpathSync(dir)) assert.equal(branches.find(b => b.name === 'feature').checkedOut, false) + assert.equal(branches.find(b => b.name === 'feature').isDefault, false) assert.equal(branches.find(b => b.name === 'feature').worktreePath, null) } finally { fs.rmSync(dir, { recursive: true, force: true }) } }) +test('listBranches: flags a free default branch as default, not checked out', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-branches-default-')) + const git = (...args) => execFileSync('git', args, { cwd: dir }).toString().trim() + + try { + await ensureGitRepo('git', dir) + const trunk = git('branch', '--show-current') + execFileSync('git', ['switch', '-c', 'rawr'], { cwd: dir }) + + const branches = await listBranches(dir, 'git') + const defaultBranch = branches.find(b => b.name === trunk) + + assert.equal(defaultBranch.checkedOut, false) + assert.equal(defaultBranch.isDefault, true) + assert.equal(defaultBranch.worktreePath, null) + } finally { + fs.rmSync(dir, { recursive: true, force: true }) + } +}) + test('listBranches: a branch claimed by a worktree is flagged checked out', async () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-branches-wt-')) @@ -170,3 +192,23 @@ test('addWorktree: existingBranch checks the branch out without a new branch', a fs.rmSync(dir, { recursive: true, force: true }) } }) + +test('addWorktree: existing default branch switches the main checkout, not .worktrees/main', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-convert-default-')) + const git = (...args) => execFileSync('git', args, { cwd: dir }).toString().trim() + + try { + await ensureGitRepo('git', dir) + const trunk = git('branch', '--show-current') + execFileSync('git', ['switch', '-c', 'rawr'], { cwd: dir }) + + const result = await addWorktree(dir, { existingBranch: trunk }, 'git') + + assert.equal(result.branch, trunk) + assert.equal(fs.realpathSync(result.path), fs.realpathSync(dir)) + assert.equal(git('branch', '--show-current'), trunk) + assert.equal(fs.existsSync(path.join(dir, '.worktrees', trunk)), false) + } finally { + fs.rmSync(dir, { recursive: true, force: true }) + } +}) diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index 66e5efe9555..8a0ec509b0b 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -1387,7 +1387,7 @@ export function ChatBar({ // Mirrors handleBranchOff's hand-off: create the worktree, then open a session // anchored there carrying the draft. const handleConvertBranch = useCallback( - async (branch: string, path?: null | string) => { + async (branch: string, path?: null | string, isDefault?: boolean) => { if (path?.trim()) { openInWorktree(path) @@ -1395,6 +1395,14 @@ export function ChatBar({ } const repoPath = cwd?.trim() + + if (repoPath && isDefault) { + await switchBranchInRepo(repoPath, branch) + openInWorktree(repoPath) + + return + } + const result = repoPath && (await startWorkInRepo(repoPath, { existingBranch: branch })) if (result) { diff --git a/apps/desktop/src/app/chat/composer/status-stack/coding-row.tsx b/apps/desktop/src/app/chat/composer/status-stack/coding-row.tsx index dee1272619c..573f5eccd70 100644 --- a/apps/desktop/src/app/chat/composer/status-stack/coding-row.tsx +++ b/apps/desktop/src/app/chat/composer/status-stack/coding-row.tsx @@ -40,6 +40,20 @@ import { $newWorktreeRequest } from '@/store/projects' // Tiny uppercase section header, matching the composer "+" menu's labels. const MENU_SECTION = 'text-[0.625rem] font-semibold uppercase tracking-wider text-(--ui-text-tertiary)' +interface BranchActionCopy { + branchCreateWorktree: string + branchOpenExisting: string + branchSwitchHome: string +} + +const branchActionLabel = (branch: HermesGitBranch, copy: BranchActionCopy) => { + if (branch.checkedOut) { + return copy.branchOpenExisting + } + + return branch.isDefault ? copy.branchSwitchHome : copy.branchCreateWorktree +} + interface CodingStatusRowProps { /** Branch the current draft off into a fresh worktree + session, based on * `base` (a branch name; omitted = current HEAD). The composer owns the @@ -48,7 +62,7 @@ interface CodingStatusRowProps { onBranchOff?: (branch: string, base?: string) => Promise /** Check an existing branch out into a fresh worktree + session (no new * branch). Drives the dialog's "convert a branch" picker. */ - onConvertBranch?: (branch: string, path?: null | string) => Promise + onConvertBranch?: (branch: string, path?: null | string, isDefault?: boolean) => Promise /** List the repo's local branches for the "convert a branch" picker. */ onListBranches?: () => Promise /** Open the review pane (changed files + diffs). */ @@ -84,15 +98,10 @@ export const CodingStatusRow = memo(function CodingStatusRow({ const [branchName, setBranchName] = useState('') const [branchBase, setBranchBase] = useState(undefined) const [branchPending, setBranchPending] = useState(false) - // "Convert an existing branch into a worktree" sub-mode of the dialog: the body - // swaps the new-branch name input for a filterable list of the repo's branches. const [convertMode, setConvertMode] = useState(false) const [branches, setBranches] = useState([]) const [branchesLoading, setBranchesLoading] = useState(false) - // Pull the repo's branches the first time the convert picker is shown for an - // open dialog. Cheap + bounded; refreshed each time the picker is entered so a - // branch created mid-session shows up. const loadBranches = useCallback(async () => { if (!onListBranches) { return @@ -119,7 +128,6 @@ export const CodingStatusRow = memo(function CodingStatusRow({ setTimeout(() => setBranchOpen(true), 0) } - // Open the dialog straight into the convert-a-branch picker. const startConvert = () => { setBranchBase(undefined) setBranchName('') @@ -128,7 +136,6 @@ export const CodingStatusRow = memo(function CodingStatusRow({ setTimeout(() => setBranchOpen(true), 0) } - // Flip an already-open dialog into the picker (the in-dialog link). const enterConvert = () => { setConvertMode(true) void loadBranches() @@ -142,7 +149,7 @@ export const CodingStatusRow = memo(function CodingStatusRow({ setBranchPending(true) try { - await onConvertBranch(branch.name, branch.worktreePath) + await onConvertBranch(branch.name, branch.worktreePath, branch.isDefault) setBranchOpen(false) } catch (err) { notifyError(err, p.startWorkFailed) @@ -393,11 +400,9 @@ export const CodingStatusRow = memo(function CodingStatusRow({ > {branch.name} - {branch.checkedOut && ( - - {p.branchCheckedOut} - - )} + + {branchActionLabel(branch, p)} + ))} @@ -423,8 +428,6 @@ export const CodingStatusRow = memo(function CodingStatusRow({ )} {convertMode ? ( - // The picker is a sub-screen: a single "Cancel" link steps back to - // the new-branch screen (the dialog's own ✕ / Esc still closes it). +
+ {modes.map(mode => ( + + ))}
) } -// Gutter and Shiki output share `font-mono text-xs leading-relaxed py-3` so -// each line aligns vertically. The selection overlay relies on the same -// `text-xs * leading-relaxed = 1.21875rem` line-height to position itself. -const SOURCE_LINE_HEIGHT_REM = 1.21875 -const SOURCE_PAD_Y_REM = 0.75 - interface LineSelection { end: number start: number @@ -337,7 +361,18 @@ function startLineDrag(event: ReactDragEvent, filePath: string, { e function SourceView({ filePath, language, text }: { filePath: string; language: string; text: string }) { const { t } = useI18n() - const lineCount = useMemo(() => Math.max(1, text.split('\n').length), [text]) + const chunks = useMemo(() => chunkTextLines(text, SOURCE_CHUNK_LINES), [text]) + const lastChunk = chunks.at(-1) + const totalLines = lastChunk ? lastChunk.start + lastChunk.lines.length : 0 + + const { afterRows, beforeRows, endChunk, onScroll, scrollerRef, startChunk } = useFixedRowWindow({ + overscanRows: SOURCE_OVERSCAN_LINES, + rowPx: SOURCE_LINE_PX, + rowsPerChunk: SOURCE_CHUNK_LINES, + totalRows: totalLines + }) + + const visibleChunks = chunks.slice(startChunk, endChunk + 1) const [selection, setSelection] = useState(null) const inSelection = (line: number) => selection != null && line >= selection.start && line <= selection.end @@ -394,69 +429,76 @@ function SourceView({ filePath, language, text }: { filePath: string; language: }, [filePath, selection]) return ( -
-
- {Array.from({ length: lineCount }, (_, index) => { - const line = index + 1 - const selected = inSelection(line) - - return ( -
handleLineClick(event, line)} - onDragStart={event => handleDragStart(event, line)} - title={t.preview.sourceLineTitle} - > - {line} -
- ) - })} -
-
- {selection && ( -
+
+
+ {beforeRows > 0 && ( +
+ )} + {visibleChunks.map(chunk => ( + +
+ {chunk.lines.map((_lineText, offset) => { + const line = chunk.start + offset + 1 + const selected = inSelection(line) + + return ( +
handleLineClick(event, line)} + onDragStart={event => handleDragStart(event, line)} + title={t.preview.sourceLineTitle} + > + {line} +
+ ) + })} +
+
+ + {chunk.text} + +
+
+ ))} + {afterRows > 0 && ( +
)} - - {text} -
) } +type PreviewViewMode = 'diff' | 'rendered' | 'source' + export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; target: PreviewTarget }) { const { t } = useI18n() const [state, setState] = useState({ loading: true }) const [forcePreview, setForcePreview] = useState(false) - const [renderMarkdownAsSource, setRenderMarkdownAsSource] = useState(false) + // User-picked view; null = auto (diff when changed, else rendered markdown, + // else source). Reset when the previewed file changes. + const [userMode, setUserMode] = useState(null) const filePath = filePathForTarget(target) const isImage = target.previewKind === 'image' + useEffect(() => { + setUserMode(null) + }, [filePath, reloadKey]) + // HTML files are rendered as source code, not in a webview - so they take // the same path as plain text files. `previewKind === 'binary'` arrives // when the file is forcibly previewed past the binary refusal screen. @@ -508,6 +550,22 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar text: shouldBlock ? undefined : result.text, truncated: result.truncated }) + + // Best-effort: fetch the file's working-tree-vs-HEAD diff so the + // preview can offer a DIFF view when there are uncommitted changes. + // Empty (clean file / not a repo / remote) just hides the option. + if (!shouldBlock) { + try { + const root = await desktopGitRoot(filePath) + const diff = root ? await desktopFileDiff(root, filePath) : '' + + if (active && diff.trim()) { + setState(prev => (prev.text === result.text ? { ...prev, diff } : prev)) + } + } catch { + // No diff available; the preview just shows source. + } + } } } catch (error) { if (active) { @@ -571,21 +629,50 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar if (isText && state.text !== undefined) { const isMarkdown = (state.language || target.language) === 'markdown' - const showRendered = isMarkdown && !renderMarkdownAsSource + const hasDiff = Boolean(state.diff && state.diff.trim()) + // Order the toggle reads left→right; default lands on the most useful view. + const modes: PreviewViewMode[] = [] + + if (isMarkdown) { + modes.push('rendered') + } + + modes.push('source') + + if (hasDiff) { + modes.push('diff') + } + + const autoMode: PreviewViewMode = hasDiff ? 'diff' : isMarkdown ? 'rendered' : 'source' + const mode = userMode && modes.includes(userMode) ? userMode : autoMode return ( -
+
{state.truncated && (
{t.preview.truncated}
)} - {isMarkdown && setRenderMarkdownAsSource(s => !s)} />} - {showRendered ? ( - - ) : ( - - )} + {modes.length > 1 && } +
+ {mode === 'rendered' ? ( + + ) : mode === 'diff' ? ( + + ) : ( + + )} +
) } diff --git a/apps/desktop/src/app/chat/right-rail/preview.tsx b/apps/desktop/src/app/chat/right-rail/preview.tsx index dec0e36f47b..97678cab106 100644 --- a/apps/desktop/src/app/chat/right-rail/preview.tsx +++ b/apps/desktop/src/app/chat/right-rail/preview.tsx @@ -3,10 +3,19 @@ import { useEffect, useMemo } from 'react' import type { SetTitlebarToolGroup } from '@/app/shell/titlebar-controls' import { Codicon } from '@/components/ui/codicon' +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger +} from '@/components/ui/context-menu' import { Tip } from '@/components/ui/tooltip' import { translateNow, useI18n } from '@/i18n' +import { formatCombo } from '@/lib/keybinds/combo' import { cn } from '@/lib/utils' import { + $panesFlipped, $rightRailActiveTabId, RIGHT_RAIL_PREVIEW_TAB_ID, type RightRailTabId, @@ -16,8 +25,10 @@ import { $filePreviewTabs, $previewReloadRequest, $previewTarget, + closeOtherRightRailTabs, closeRightRail, closeRightRailTab, + closeRightRailTabsToRight, type PreviewTarget } from '@/store/preview' @@ -56,6 +67,7 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP const { t } = useI18n() const previewReloadRequest = useStore($previewReloadRequest) const activeTabId = useStore($rightRailActiveTabId) + const panesFlipped = useStore($panesFlipped) const filePreviewTabs = useStore($filePreviewTabs) const previewTarget = useStore($previewTarget) @@ -82,68 +94,92 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP const isPreview = activeTab.id === RIGHT_RAIL_PREVIEW_TAB_ID return ( -