mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-27 11:22:03 +00:00
fix(desktop): clarify branch convert actions
Open checked-out branches, switch the primary checkout for the default branch, and create linked worktrees only for non-trunk free branches.
This commit is contained in:
parent
9f3aa1685c
commit
19ca295a84
13 changed files with 433 additions and 187 deletions
|
|
@ -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 []
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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<void>
|
||||
/** 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<void>
|
||||
onConvertBranch?: (branch: string, path?: null | string, isDefault?: boolean) => Promise<void>
|
||||
/** List the repo's local branches for the "convert a branch" picker. */
|
||||
onListBranches?: () => Promise<HermesGitBranch[]>
|
||||
/** 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<string | undefined>(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<HermesGitBranch[]>([])
|
||||
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({
|
|||
>
|
||||
<Codicon className="shrink-0 text-(--ui-text-tertiary)" name="git-branch" size="0.8rem" />
|
||||
<span className="truncate">{branch.name}</span>
|
||||
{branch.checkedOut && (
|
||||
<span className="ml-auto shrink-0 text-[0.625rem] text-(--ui-text-tertiary)">
|
||||
{p.branchCheckedOut}
|
||||
</span>
|
||||
)}
|
||||
<span className="ml-auto shrink-0 text-[0.625rem] text-(--ui-text-tertiary)">
|
||||
{branchActionLabel(branch, p)}
|
||||
</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
|
|
@ -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).
|
||||
<DialogFooter className="sm:justify-start">
|
||||
<Button
|
||||
className="px-0 text-(--ui-text-secondary) hover:text-foreground"
|
||||
|
|
@ -438,7 +441,6 @@ export const CodingStatusRow = memo(function CodingStatusRow({
|
|||
</DialogFooter>
|
||||
) : (
|
||||
<DialogFooter className="sm:justify-between">
|
||||
{/* Switch into the convert-an-existing-branch picker. */}
|
||||
{onConvertBranch ? (
|
||||
<Button
|
||||
className="px-0 text-(--ui-text-secondary) hover:text-foreground"
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import type {
|
|||
MouseEvent as ReactMouseEvent,
|
||||
ReactNode
|
||||
} from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Fragment, useEffect, useMemo, useState } from 'react'
|
||||
import ShikiHighlighter from 'react-shiki'
|
||||
import { Streamdown } from 'streamdown'
|
||||
|
||||
|
|
@ -14,15 +14,21 @@ import { requestComposerFocus, requestComposerInsertRefs } from '@/app/chat/comp
|
|||
import { droppedFileInlineRef } from '@/app/chat/composer/inline-refs'
|
||||
import { HERMES_PATHS_MIME } from '@/app/chat/hooks/use-composer-actions'
|
||||
import { isAddSelectionShortcut } from '@/app/right-sidebar/terminal/selection'
|
||||
import { FileDiffPanel } from '@/components/chat/diff-lines'
|
||||
import { chunkTextLines, useFixedRowWindow } from '@/components/chat/fixed-row-window'
|
||||
import { PageLoader } from '@/components/page-loader'
|
||||
import { translateNow, useI18n } from '@/i18n'
|
||||
import { readDesktopFileDataUrl, readDesktopFileText } from '@/lib/desktop-fs'
|
||||
import { desktopFileDiff, desktopGitRoot, readDesktopFileDataUrl, readDesktopFileText } from '@/lib/desktop-fs'
|
||||
import { shikiLanguageForFilename } from '@/lib/markdown-code'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { PreviewTarget } from '@/store/preview'
|
||||
import { $currentCwd } from '@/store/session'
|
||||
|
||||
const SHIKI_THEME = { dark: 'github-dark-default', light: 'github-light-default' } as const
|
||||
const TEXT_PREVIEW_MAX_BYTES = 512 * 1024
|
||||
const SOURCE_CHUNK_LINES = 200
|
||||
const SOURCE_LINE_PX = 20
|
||||
const SOURCE_OVERSCAN_LINES = 400
|
||||
|
||||
type EmptyStateTone = 'neutral' | 'warning'
|
||||
|
||||
|
|
@ -126,6 +132,8 @@ interface LocalPreviewState {
|
|||
binary?: boolean
|
||||
byteSize?: number
|
||||
dataUrl?: string
|
||||
/** Working-tree-vs-HEAD unified diff, when the file has uncommitted changes. */
|
||||
diff?: string
|
||||
error?: string
|
||||
language?: string
|
||||
loading: boolean
|
||||
|
|
@ -299,28 +307,44 @@ function MarkdownPreview({ text }: { text: string }) {
|
|||
)
|
||||
}
|
||||
|
||||
function PreviewToggle({ asSource, onToggle }: { asSource: boolean; onToggle: () => void }) {
|
||||
function PreviewModeSwitcher({
|
||||
active,
|
||||
modes,
|
||||
onSelect
|
||||
}: {
|
||||
active: PreviewViewMode
|
||||
modes: PreviewViewMode[]
|
||||
onSelect: (mode: PreviewViewMode) => void
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
|
||||
const label: Record<PreviewViewMode, string> = {
|
||||
diff: t.preview.diff,
|
||||
rendered: t.preview.renderedPreview,
|
||||
source: t.preview.source
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="sticky top-0 z-10 flex justify-end border-b border-border/40 bg-transparent px-3 py-1 backdrop-blur">
|
||||
<button
|
||||
className="text-[0.625rem] font-bold text-muted-foreground underline decoration-current/20 underline-offset-4 transition-colors hover:text-foreground"
|
||||
onClick={onToggle}
|
||||
type="button"
|
||||
>
|
||||
{asSource ? t.preview.renderedPreview : t.preview.source}
|
||||
</button>
|
||||
<div className="flex shrink-0 justify-end gap-3 border-b border-border/40 px-3 py-1">
|
||||
{modes.map(mode => (
|
||||
<button
|
||||
className={cn(
|
||||
'text-[0.625rem] font-bold underline-offset-4 transition-colors',
|
||||
mode === active
|
||||
? 'text-foreground underline decoration-current/30'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
key={mode}
|
||||
onClick={() => onSelect(mode)}
|
||||
type="button"
|
||||
>
|
||||
{label[mode]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 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<HTMLElement>, 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<LineSelection | null>(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 (
|
||||
<div className="grid min-w-max grid-cols-[auto_minmax(0,1fr)] font-mono text-xs leading-relaxed">
|
||||
<div className="select-none py-3 text-right text-muted-foreground/55">
|
||||
{Array.from({ length: lineCount }, (_, index) => {
|
||||
const line = index + 1
|
||||
const selected = inSelection(line)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'cursor-pointer px-3 tabular-nums transition-colors',
|
||||
selected
|
||||
? 'bg-amber-200/45 text-amber-900 dark:bg-amber-300/20 dark:text-amber-100'
|
||||
: 'hover:text-foreground'
|
||||
)}
|
||||
draggable
|
||||
key={line}
|
||||
onClick={event => handleLineClick(event, line)}
|
||||
onDragStart={event => handleDragStart(event, line)}
|
||||
title={t.preview.sourceLineTitle}
|
||||
>
|
||||
{line}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div
|
||||
className="relative [&_pre]:m-0 [&_pre]:px-3 [&_pre]:py-3 [&_pre]:bg-transparent!"
|
||||
data-selectable-text="true"
|
||||
>
|
||||
{selection && (
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-x-0 bg-amber-200/35 dark:bg-amber-300/10"
|
||||
style={{
|
||||
top: `calc(${SOURCE_PAD_Y_REM}rem + ${selection.start - 1} * ${SOURCE_LINE_HEIGHT_REM}rem)`,
|
||||
height: `calc(${selection.end - selection.start + 1} * ${SOURCE_LINE_HEIGHT_REM}rem)`
|
||||
}}
|
||||
/>
|
||||
<div className="h-full overflow-auto" onScroll={onScroll} ref={scrollerRef}>
|
||||
<div className="grid min-w-max grid-cols-[auto_minmax(0,1fr)] font-mono text-[0.7rem] leading-relaxed">
|
||||
{beforeRows > 0 && (
|
||||
<div aria-hidden className="col-span-2" style={{ height: beforeRows * SOURCE_LINE_PX }} />
|
||||
)}
|
||||
{visibleChunks.map(chunk => (
|
||||
<Fragment key={chunk.start}>
|
||||
<div className="select-none text-right text-muted-foreground/55">
|
||||
{chunk.lines.map((_lineText, offset) => {
|
||||
const line = chunk.start + offset + 1
|
||||
const selected = inSelection(line)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'h-5 w-9 cursor-pointer pr-2 leading-5 tabular-nums transition-colors',
|
||||
selected
|
||||
? 'bg-amber-200/45 text-amber-900 dark:bg-amber-300/20 dark:text-amber-100'
|
||||
: 'hover:text-foreground'
|
||||
)}
|
||||
draggable
|
||||
key={line}
|
||||
onClick={event => handleLineClick(event, line)}
|
||||
onDragStart={event => handleDragStart(event, line)}
|
||||
title={t.preview.sourceLineTitle}
|
||||
>
|
||||
{line}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="preview-source-code min-w-0 [&_pre]:m-0" data-selectable-text="true">
|
||||
<ShikiHighlighter
|
||||
addDefaultStyles={false}
|
||||
as="div"
|
||||
defaultColor="light-dark()"
|
||||
delay={80}
|
||||
language={language || 'text'}
|
||||
showLanguage={false}
|
||||
theme={SHIKI_THEME}
|
||||
>
|
||||
{chunk.text}
|
||||
</ShikiHighlighter>
|
||||
</div>
|
||||
</Fragment>
|
||||
))}
|
||||
{afterRows > 0 && (
|
||||
<div aria-hidden className="col-span-2" style={{ height: afterRows * SOURCE_LINE_PX }} />
|
||||
)}
|
||||
<ShikiHighlighter
|
||||
addDefaultStyles={false}
|
||||
as="div"
|
||||
defaultColor="light-dark()"
|
||||
delay={80}
|
||||
language={language || 'text'}
|
||||
showLanguage={false}
|
||||
theme={SHIKI_THEME}
|
||||
>
|
||||
{text}
|
||||
</ShikiHighlighter>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type PreviewViewMode = 'diff' | 'rendered' | 'source'
|
||||
|
||||
export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; target: PreviewTarget }) {
|
||||
const { t } = useI18n()
|
||||
const [state, setState] = useState<LocalPreviewState>({ 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 | PreviewViewMode>(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 (
|
||||
<div className="h-full overflow-auto bg-transparent">
|
||||
<div className="flex h-full flex-col overflow-hidden bg-transparent">
|
||||
{state.truncated && (
|
||||
<div className="border-b border-border/60 bg-muted/35 px-3 py-1.5 text-[0.68rem] text-muted-foreground">
|
||||
{t.preview.truncated}
|
||||
</div>
|
||||
)}
|
||||
{isMarkdown && <PreviewToggle asSource={!showRendered} onToggle={() => setRenderMarkdownAsSource(s => !s)} />}
|
||||
{showRendered ? (
|
||||
<MarkdownPreview text={state.text} />
|
||||
) : (
|
||||
<SourceView filePath={filePath} language={state.language || 'text'} text={state.text} />
|
||||
)}
|
||||
{modes.length > 1 && <PreviewModeSwitcher active={mode} modes={modes} onSelect={setUserMode} />}
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
{mode === 'rendered' ? (
|
||||
<MarkdownPreview text={state.text} />
|
||||
) : mode === 'diff' ? (
|
||||
<FileDiffPanel
|
||||
className="mx-0 mb-0 h-full max-h-none"
|
||||
diff={state.diff ?? ''}
|
||||
fullText={state.text}
|
||||
path={filePath}
|
||||
showLineNumbers
|
||||
/>
|
||||
) : (
|
||||
<SourceView
|
||||
filePath={filePath}
|
||||
language={shikiLanguageForFilename(filePath) || state.language || 'text'}
|
||||
text={state.text}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<aside className="relative flex h-full w-full min-w-0 flex-col overflow-hidden border-l border-(--ui-stroke-tertiary) bg-(--ui-editor-surface-background) text-(--ui-text-tertiary)">
|
||||
<aside
|
||||
className={cn(
|
||||
'relative flex h-full w-full min-w-0 flex-col overflow-hidden border-(--ui-stroke-tertiary) bg-(--ui-editor-surface-background) text-(--ui-text-tertiary)',
|
||||
panesFlipped ? 'border-r' : 'border-l'
|
||||
)}
|
||||
>
|
||||
<div className="group/rail-tabs flex h-(--titlebar-height) shrink-0 border-b border-(--ui-stroke-tertiary) bg-(--ui-sidebar-surface-background)">
|
||||
<div
|
||||
className="flex min-w-0 flex-1 overflow-x-auto overflow-y-hidden overscroll-x-contain [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||
role="tablist"
|
||||
>
|
||||
{tabs.map(tab => {
|
||||
{tabs.map((tab, index) => {
|
||||
const active = tab.id === activeTab.id
|
||||
const hasOthers = tabs.length > 1
|
||||
const hasTabsToRight = index < tabs.length - 1
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group/tab relative flex h-full min-w-0 max-w-48 shrink-0 items-center text-[0.6875rem] font-medium [-webkit-app-region:no-drag] last:border-r last:border-(--ui-stroke-quaternary)',
|
||||
active
|
||||
? 'bg-(--ui-editor-surface-background) text-foreground [--tab-bg:var(--ui-editor-surface-background)]'
|
||||
: 'border-r border-(--ui-stroke-quaternary) text-(--ui-text-tertiary) [--tab-bg:var(--ui-sidebar-surface-background)] hover:bg-(--chrome-action-hover) hover:text-foreground'
|
||||
)}
|
||||
key={tab.id}
|
||||
// Middle-click closes the tab, matching browser/IDE muscle
|
||||
// memory. `onMouseDown` swallows the middle-button press so
|
||||
// Chromium doesn't switch into autoscroll mode.
|
||||
onAuxClick={event => {
|
||||
if (event.button !== 1) {
|
||||
return
|
||||
}
|
||||
<ContextMenu key={tab.id}>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
'group/tab relative flex h-full min-w-0 max-w-48 shrink-0 items-center text-[0.6875rem] font-medium [-webkit-app-region:no-drag] last:border-r last:border-(--ui-stroke-quaternary)',
|
||||
active
|
||||
? 'bg-(--ui-editor-surface-background) text-foreground [--tab-bg:var(--ui-editor-surface-background)]'
|
||||
: 'border-r border-(--ui-stroke-quaternary) text-(--ui-text-tertiary) [--tab-bg:var(--ui-sidebar-surface-background)] hover:bg-(--chrome-action-hover) hover:text-foreground'
|
||||
)}
|
||||
// Middle-click closes the tab, matching browser/IDE muscle
|
||||
// memory. `onMouseDown` swallows the middle-button press so
|
||||
// Chromium doesn't switch into autoscroll mode.
|
||||
onAuxClick={event => {
|
||||
if (event.button !== 1) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
closeRightRailTab(tab.id)
|
||||
}}
|
||||
onMouseDown={event => {
|
||||
if (event.button === 1) {
|
||||
event.preventDefault()
|
||||
}
|
||||
}}
|
||||
>
|
||||
{active && (
|
||||
<span aria-hidden="true" className="absolute inset-x-0 top-0 h-px bg-(--ui-stroke-primary)" />
|
||||
)}
|
||||
<Tip label={tab.label}>
|
||||
<button
|
||||
aria-selected={active}
|
||||
className="flex h-full min-w-0 max-w-full items-center overflow-hidden pl-3 pr-2 text-left outline-none"
|
||||
onClick={() => selectRightRailTab(tab.id)}
|
||||
role="tab"
|
||||
type="button"
|
||||
event.preventDefault()
|
||||
closeRightRailTab(tab.id)
|
||||
}}
|
||||
onMouseDown={event => {
|
||||
if (event.button === 1) {
|
||||
event.preventDefault()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="block min-w-0 truncate">{tab.label}</span>
|
||||
</button>
|
||||
</Tip>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-y-0 right-0 w-9 bg-[linear-gradient(to_right,transparent,var(--tab-bg)_55%)] opacity-0 transition-opacity group-hover/tab:opacity-100 group-focus-within/tab:opacity-100"
|
||||
/>
|
||||
<button
|
||||
aria-label={t.preview.closeTab(tab.label)}
|
||||
className="pointer-events-none absolute right-1.5 top-1/2 grid size-4 -translate-y-1/2 place-items-center rounded-sm text-(--ui-text-tertiary) opacity-0 transition-[background-color,color,opacity] hover:bg-(--ui-bg-secondary) hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-hover/tab:pointer-events-auto group-hover/tab:opacity-100 group-focus-within/tab:pointer-events-auto group-focus-within/tab:opacity-100"
|
||||
onClick={() => closeRightRailTab(tab.id)}
|
||||
type="button"
|
||||
>
|
||||
<Codicon name="close" size="0.75rem" />
|
||||
</button>
|
||||
</div>
|
||||
{active && (
|
||||
<span aria-hidden="true" className="absolute inset-x-0 top-0 h-px bg-(--ui-stroke-primary)" />
|
||||
)}
|
||||
<Tip label={tab.target.path || tab.target.url || tab.label}>
|
||||
<button
|
||||
aria-selected={active}
|
||||
className="flex h-full min-w-0 max-w-full items-center overflow-hidden pl-3 pr-2 text-left outline-none"
|
||||
onClick={() => selectRightRailTab(tab.id)}
|
||||
role="tab"
|
||||
type="button"
|
||||
>
|
||||
<span className="block min-w-0 truncate">{tab.label}</span>
|
||||
</button>
|
||||
</Tip>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-y-0 right-0 w-9 bg-[linear-gradient(to_right,transparent,var(--tab-bg)_55%)] opacity-0 transition-opacity group-hover/tab:opacity-100 group-focus-within/tab:opacity-100"
|
||||
/>
|
||||
<button
|
||||
aria-label={t.preview.closeTab(tab.label)}
|
||||
className="pointer-events-none absolute right-1.5 top-1/2 grid size-4 -translate-y-1/2 place-items-center rounded-sm text-(--ui-text-tertiary) opacity-0 transition-[background-color,color,opacity] hover:bg-(--ui-bg-secondary) hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-hover/tab:pointer-events-auto group-hover/tab:opacity-100 group-focus-within/tab:pointer-events-auto group-focus-within/tab:opacity-100"
|
||||
onClick={() => closeRightRailTab(tab.id)}
|
||||
type="button"
|
||||
>
|
||||
<Codicon name="close" size="0.75rem" />
|
||||
</button>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onSelect={() => closeRightRailTab(tab.id)}>
|
||||
{t.common.close}
|
||||
<span className="ml-auto pl-4 text-(--ui-text-tertiary)">{formatCombo('mod+w')}</span>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem disabled={!hasOthers} onSelect={() => closeOtherRightRailTabs(tab.id)}>
|
||||
{t.preview.closeOthers}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem disabled={!hasTabsToRight} onSelect={() => closeRightRailTabsToRight(tab.id)}>
|
||||
{t.preview.closeToRight}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onSelect={closeRightRail}>{t.preview.closeAll}</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ import { useI18n } from '@/i18n'
|
|||
import { gitRef } from '@/lib/sanitize'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import { copyPath, listRepoBranches, revealPath, startWorkInRepo } from '@/store/projects'
|
||||
import { copyPath, listRepoBranches, revealPath, startWorkInRepo, switchBranchInRepo } from '@/store/projects'
|
||||
|
||||
import { SidebarCount, SidebarRowLead } from '../chrome'
|
||||
|
||||
|
|
@ -41,6 +41,20 @@ function LaneLabel({ label, title }: { label: string; title?: string }) {
|
|||
)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// "+" affordance shared by repo and worktree headers — reveals on header hover.
|
||||
export function WorkspaceAddButton({ label, onClick }: { label: string; onClick: () => void }) {
|
||||
return (
|
||||
|
|
@ -120,14 +134,10 @@ export function StartWorkButton({ repoPath, onStarted }: { repoPath: string; onS
|
|||
const [open, setOpen] = useState(false)
|
||||
const [name, setName] = useState('')
|
||||
const [pending, setPending] = useState(false)
|
||||
// "Convert an existing branch into a worktree" sub-mode: 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<HermesGitBranch[]>([])
|
||||
const [branchesLoading, setBranchesLoading] = useState(false)
|
||||
|
||||
// Pull the repo's branches each time the picker is entered (cheap + bounded),
|
||||
// so a branch created mid-session shows up.
|
||||
const loadBranches = useCallback(async () => {
|
||||
if (!repoPath) {
|
||||
return
|
||||
|
|
@ -170,7 +180,6 @@ export function StartWorkButton({ repoPath, onStarted }: { repoPath: string; onS
|
|||
}
|
||||
}
|
||||
|
||||
// Check an EXISTING branch out into a fresh worktree (no new branch).
|
||||
const convert = async (branch: HermesGitBranch) => {
|
||||
if (pending || !repoPath || !branch) {
|
||||
return
|
||||
|
|
@ -179,9 +188,16 @@ export function StartWorkButton({ repoPath, onStarted }: { repoPath: string; onS
|
|||
setPending(true)
|
||||
|
||||
try {
|
||||
const result = branch.worktreePath
|
||||
? { branch: branch.name, path: branch.worktreePath }
|
||||
: await startWorkInRepo(repoPath, { existingBranch: branch.name })
|
||||
let result: null | { branch: string; path: string }
|
||||
|
||||
if (branch.worktreePath) {
|
||||
result = { branch: branch.name, path: branch.worktreePath }
|
||||
} else if (branch.isDefault) {
|
||||
await switchBranchInRepo(repoPath, branch.name)
|
||||
result = { branch: branch.name, path: repoPath }
|
||||
} else {
|
||||
result = await startWorkInRepo(repoPath, { existingBranch: branch.name })
|
||||
}
|
||||
|
||||
if (result) {
|
||||
onStarted(result.path)
|
||||
|
|
@ -238,11 +254,9 @@ export function StartWorkButton({ repoPath, onStarted }: { repoPath: string; onS
|
|||
>
|
||||
<Codicon className="shrink-0 text-(--ui-text-tertiary)" name="git-branch" size="0.8rem" />
|
||||
<span className="truncate">{branch.name}</span>
|
||||
{branch.checkedOut && (
|
||||
<span className="ml-auto shrink-0 text-[0.625rem] text-(--ui-text-tertiary)">
|
||||
{p.branchCheckedOut}
|
||||
</span>
|
||||
)}
|
||||
<span className="ml-auto shrink-0 text-[0.625rem] text-(--ui-text-tertiary)">
|
||||
{branchActionLabel(branch, p)}
|
||||
</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
|
|
@ -268,8 +282,6 @@ export function StartWorkButton({ repoPath, onStarted }: { repoPath: string; onS
|
|||
)}
|
||||
|
||||
{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).
|
||||
<DialogFooter className="sm:justify-start">
|
||||
<Button
|
||||
className="px-0 text-(--ui-text-secondary) hover:text-foreground"
|
||||
|
|
@ -283,7 +295,6 @@ export function StartWorkButton({ repoPath, onStarted }: { repoPath: string; onS
|
|||
</DialogFooter>
|
||||
) : (
|
||||
<DialogFooter className="sm:justify-between">
|
||||
{/* Switch into the convert-an-existing-branch picker. */}
|
||||
<Button
|
||||
className="px-0 text-(--ui-text-secondary) hover:text-foreground"
|
||||
disabled={pending}
|
||||
|
|
|
|||
4
apps/desktop/src/global.d.ts
vendored
4
apps/desktop/src/global.d.ts
vendored
|
|
@ -576,10 +576,12 @@ export interface HermesGitWorktree {
|
|||
}
|
||||
|
||||
// A local branch as offered by the "convert a branch into a worktree" picker.
|
||||
// `checkedOut` marks branches git won't let a second worktree claim.
|
||||
// `checkedOut` means selecting opens that checkout; `isDefault` means selecting
|
||||
// switches the main checkout instead of creating `.worktrees/main`.
|
||||
export interface HermesGitBranch {
|
||||
name: string
|
||||
checkedOut: boolean
|
||||
isDefault: boolean
|
||||
worktreePath: null | string
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1322,10 +1322,12 @@ export const en: Translations = {
|
|||
startWorkFailed: 'Could not create worktree',
|
||||
convertBranch: 'Convert a branch…',
|
||||
convertBranchTitle: 'Convert a branch',
|
||||
convertBranchDesc: 'Check an existing branch out into a new worktree.',
|
||||
convertBranchDesc: 'Open checked-out branches, or create a worktree for a free branch.',
|
||||
convertBranchPlaceholder: 'Search branches…',
|
||||
convertBranchInstead: 'Convert an existing branch',
|
||||
branchCheckedOut: 'checked out',
|
||||
branchOpenExisting: 'open',
|
||||
branchSwitchHome: 'switch home',
|
||||
branchCreateWorktree: 'new worktree',
|
||||
branchesLoading: 'Loading branches…',
|
||||
noBranches: 'No branches found',
|
||||
removeWorktree: 'Remove worktree',
|
||||
|
|
|
|||
|
|
@ -1446,10 +1446,12 @@ export const ja = defineLocale({
|
|||
startWorkFailed: 'ワークツリーを作成できませんでした',
|
||||
convertBranch: 'ブランチを変換…',
|
||||
convertBranchTitle: 'ブランチを変換',
|
||||
convertBranchDesc: '既存のブランチを新しいワークツリーにチェックアウトします。',
|
||||
convertBranchDesc: 'チェックアウト済みのブランチを開くか、空いているブランチのワークツリーを作成します。',
|
||||
convertBranchPlaceholder: 'ブランチを検索…',
|
||||
convertBranchInstead: '既存のブランチを変換',
|
||||
branchCheckedOut: 'チェックアウト済み',
|
||||
branchOpenExisting: '開く',
|
||||
branchSwitchHome: 'ホームを切替',
|
||||
branchCreateWorktree: '新しいワークツリー',
|
||||
branchesLoading: 'ブランチを読み込み中…',
|
||||
noBranches: 'ブランチが見つかりません',
|
||||
removeWorktree: 'ワークツリーを削除',
|
||||
|
|
|
|||
|
|
@ -1076,7 +1076,9 @@ export interface Translations {
|
|||
convertBranchDesc: string
|
||||
convertBranchPlaceholder: string
|
||||
convertBranchInstead: string
|
||||
branchCheckedOut: string
|
||||
branchOpenExisting: string
|
||||
branchSwitchHome: string
|
||||
branchCreateWorktree: string
|
||||
branchesLoading: string
|
||||
noBranches: string
|
||||
removeWorktree: string
|
||||
|
|
|
|||
|
|
@ -1399,10 +1399,12 @@ export const zhHant = defineLocale({
|
|||
startWorkFailed: '無法建立工作樹',
|
||||
convertBranch: '轉換分支…',
|
||||
convertBranchTitle: '轉換分支',
|
||||
convertBranchDesc: '將現有分支簽出到新的工作樹。',
|
||||
convertBranchDesc: '開啟已簽出的分支,或為可用分支建立工作樹。',
|
||||
convertBranchPlaceholder: '搜尋分支…',
|
||||
convertBranchInstead: '轉換現有分支',
|
||||
branchCheckedOut: '已簽出',
|
||||
branchOpenExisting: '開啟',
|
||||
branchSwitchHome: '切回主簽出',
|
||||
branchCreateWorktree: '新增工作樹',
|
||||
branchesLoading: '正在載入分支…',
|
||||
noBranches: '找不到分支',
|
||||
removeWorktree: '移除工作樹',
|
||||
|
|
|
|||
|
|
@ -1506,10 +1506,12 @@ export const zh: Translations = {
|
|||
startWorkFailed: '无法创建工作树',
|
||||
convertBranch: '转换分支…',
|
||||
convertBranchTitle: '转换分支',
|
||||
convertBranchDesc: '将现有分支检出到新的工作树。',
|
||||
convertBranchDesc: '打开已检出的分支,或为可用分支创建工作树。',
|
||||
convertBranchPlaceholder: '搜索分支…',
|
||||
convertBranchInstead: '转换现有分支',
|
||||
branchCheckedOut: '已检出',
|
||||
branchOpenExisting: '打开',
|
||||
branchSwitchHome: '切回主检出',
|
||||
branchCreateWorktree: '新工作树',
|
||||
branchesLoading: '正在加载分支…',
|
||||
noBranches: '未找到分支',
|
||||
removeWorktree: '移除工作树',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue