diff --git a/src/renderer/src/components/editor/EditorPanelHeader.tsx b/src/renderer/src/components/editor/EditorPanelHeader.tsx
index 1e553ed4e..1df03df78 100644
--- a/src/renderer/src/components/editor/EditorPanelHeader.tsx
+++ b/src/renderer/src/components/editor/EditorPanelHeader.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useMemo, useState } from 'react'
+import { useEffect, useMemo, useRef, useState } from 'react'
import {
Columns2,
Copy,
@@ -7,6 +7,7 @@ import {
FileText,
ListTree,
MoreHorizontal,
+ Pencil,
Rows2
} from 'lucide-react'
import { useAppStore } from '@/store'
@@ -19,6 +20,7 @@ import {
DropdownMenuShortcut,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
+import { Input } from '@/components/ui/input'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { CLOSE_ALL_CONTEXT_MENUS_EVENT } from '../tab-bar/SortableTab'
import { useShortcutLabel } from '@/hooks/useShortcutLabel'
@@ -30,6 +32,7 @@ import type { EditorToggleValue } from './EditorViewToggle'
import type { EditorHeaderOpenFileState } from './editor-header'
import { getEditorHeaderCopyState } from './editor-header'
import { DiffNotesSendMenu } from './DiffNotesSendMenu'
+import { useEditorHeaderFileRename } from './editor-header-file-rename'
const isMac = navigator.userAgent.includes('Mac')
const isLinux = navigator.userAgent.includes('Linux')
@@ -104,7 +107,17 @@ export function EditorPanelHeader({
}: EditorPanelHeaderProps): React.JSX.Element {
const [pathMenuOpen, setPathMenuOpen] = useState(false)
const [pathMenuPoint, setPathMenuPoint] = useState({ x: 0, y: 0 })
+ const skipMenuFocusRestoreRef = useRef(false)
const headerCopyState = getEditorHeaderCopyState(activeFile)
+ const {
+ canRename,
+ currentFileName,
+ isRenaming,
+ renameInputRef,
+ openRenameInput,
+ commitRename,
+ cancelRename
+ } = useEditorHeaderFileRename(activeFile)
const diffComments = useAppStore((s) => s.getDiffComments(activeFile.worktreeId))
const activeGroupId = useAppStore((s) => s.activeGroupIdByWorktree[activeFile.worktreeId])
const fileDiffComments = useMemo(
@@ -131,14 +144,43 @@ export function EditorPanelHeader({
setPathMenuOpen(true)
}}
>
-
+ {isRenaming ? (
+ event.stopPropagation()}
+ onMouseDown={(event) => event.stopPropagation()}
+ onClick={(event) => event.stopPropagation()}
+ onDoubleClick={(event) => event.stopPropagation()}
+ onKeyDown={(event) => {
+ if (event.key === 'Enter') {
+ event.preventDefault()
+ event.stopPropagation()
+ commitRename()
+ } else if (event.key === 'Escape') {
+ event.preventDefault()
+ event.stopPropagation()
+ cancelRename()
+ }
+ }}
+ onBlur={commitRename}
+ />
+ ) : (
+
+ )}
-
+ {
+ if (!skipMenuFocusRestoreRef.current) {
+ return
+ }
+ skipMenuFocusRestoreRef.current = false
+ event.preventDefault()
+ }}
+ >
+ {
+ skipMenuFocusRestoreRef.current = true
+ openRenameInput()
+ }}
+ >
+
+ Rename
+
+
{
void window.api.ui.writeClipboardText(activeFile.filePath)
diff --git a/src/renderer/src/components/editor/editor-header-file-rename.ts b/src/renderer/src/components/editor/editor-header-file-rename.ts
new file mode 100644
index 000000000..51af400c2
--- /dev/null
+++ b/src/renderer/src/components/editor/editor-header-file-rename.ts
@@ -0,0 +1,94 @@
+import { useEffect, useRef, useState } from 'react'
+import type { OpenFile } from '@/store/slices/editor'
+import { useWorktreeById } from '@/store/selectors'
+import { basename } from '@/lib/path'
+import { renameFileOnDisk } from '@/lib/rename-file'
+import { getUntitledFileRoot } from './untitled-file-rename-path'
+
+type EditorHeaderFileRenameState = {
+ canRename: boolean
+ currentFileName: string
+ isRenaming: boolean
+ renameInputRef: React.RefObject
+ openRenameInput: () => void
+ commitRename: () => void
+ cancelRename: () => void
+}
+
+export function useEditorHeaderFileRename(activeFile: OpenFile): EditorHeaderFileRenameState {
+ const worktree = useWorktreeById(activeFile.worktreeId)
+ const [isRenaming, setIsRenaming] = useState(false)
+ const renameInputRef = useRef(null)
+ const renameCancelledRef = useRef(false)
+ const currentFileName = basename(activeFile.filePath)
+ const canRename =
+ activeFile.mode === 'edit' && !activeFile.diffSource && !activeFile.conflict && !isRenaming
+
+ const openRenameInput = (): void => {
+ if (!canRename) {
+ return
+ }
+ renameCancelledRef.current = false
+ setIsRenaming(true)
+ }
+
+ const commitRename = (): void => {
+ if (renameCancelledRef.current) {
+ renameCancelledRef.current = false
+ setIsRenaming(false)
+ return
+ }
+ const input = renameInputRef.current
+ if (!input) {
+ setIsRenaming(false)
+ return
+ }
+ const newName = input.value.trim()
+ setIsRenaming(false)
+ if (!newName || newName === currentFileName) {
+ return
+ }
+ const worktreePath = getUntitledFileRoot(activeFile, worktree?.path ?? null)
+ void renameFileOnDisk({
+ oldPath: activeFile.filePath,
+ newName,
+ worktreeId: activeFile.worktreeId,
+ worktreePath
+ })
+ }
+
+ const cancelRename = (): void => {
+ renameCancelledRef.current = true
+ setIsRenaming(false)
+ }
+
+ useEffect(() => {
+ if (!isRenaming) {
+ return
+ }
+ const raf = requestAnimationFrame(() => {
+ const el = renameInputRef.current
+ if (!el) {
+ return
+ }
+ el.focus()
+ const dotIndex = currentFileName.lastIndexOf('.')
+ if (dotIndex > 0) {
+ el.setSelectionRange(0, dotIndex)
+ } else {
+ el.select()
+ }
+ })
+ return () => cancelAnimationFrame(raf)
+ }, [currentFileName, isRenaming])
+
+ return {
+ canRename,
+ currentFileName,
+ isRenaming,
+ renameInputRef,
+ openRenameInput,
+ commitRename,
+ cancelRename
+ }
+}
diff --git a/src/renderer/src/components/tab-bar/EditorFileTab.test.tsx b/src/renderer/src/components/tab-bar/EditorFileTab.test.tsx
new file mode 100644
index 000000000..8ac3494c6
--- /dev/null
+++ b/src/renderer/src/components/tab-bar/EditorFileTab.test.tsx
@@ -0,0 +1,307 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import type { OpenFile } from '../../store/slices/editor'
+
+const reactHookRuntime = vi.hoisted(() => ({
+ states: [] as unknown[],
+ index: 0
+}))
+
+const appStoreMocks = vi.hoisted(() => ({
+ openMarkdownPreview: vi.fn(),
+ getState: vi.fn(() => ({
+ settings: {}
+ }))
+}))
+
+vi.mock('react', async () => {
+ const actual = await vi.importActual('react') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import()
+ return {
+ ...actual,
+ useEffect: () => {},
+ useRef(initial: T) {
+ return { current: initial }
+ },
+ useState(initial: T | (() => T)) {
+ const stateIndex = reactHookRuntime.index++
+ if (!(stateIndex in reactHookRuntime.states)) {
+ reactHookRuntime.states[stateIndex] =
+ typeof initial === 'function' ? (initial as () => T)() : initial
+ }
+ const setState = (next: T | ((previous: T) => T)): void => {
+ reactHookRuntime.states[stateIndex] =
+ typeof next === 'function'
+ ? (next as (previous: T) => T)(reactHookRuntime.states[stateIndex] as T)
+ : next
+ }
+ return [reactHookRuntime.states[stateIndex] as T, setState] as const
+ }
+ }
+})
+
+vi.mock('@dnd-kit/sortable', () => ({
+ useSortable: () => ({
+ attributes: {},
+ listeners: { onPointerDown: vi.fn() },
+ setNodeRef: vi.fn()
+ })
+}))
+
+vi.mock('lucide-react', () => ({
+ Columns2: function Columns2(props: Record) {
+ return { type: 'Columns2', props }
+ },
+ Copy: function Copy(props: Record) {
+ return { type: 'Copy', props }
+ },
+ ExternalLink: function ExternalLink(props: Record) {
+ return { type: 'ExternalLink', props }
+ },
+ Eye: function Eye(props: Record) {
+ return { type: 'Eye', props }
+ },
+ GitCompareArrows: function GitCompareArrows(props: Record) {
+ return { type: 'GitCompareArrows', props }
+ },
+ Pencil: function Pencil(props: Record) {
+ return { type: 'Pencil', props }
+ },
+ Rows2: function Rows2(props: Record) {
+ return { type: 'Rows2', props }
+ },
+ ShieldAlert: function ShieldAlert(props: Record) {
+ return { type: 'ShieldAlert', props }
+ },
+ X: function X(props: Record) {
+ return { type: 'X', props }
+ }
+}))
+
+vi.mock('@/components/ui/dropdown-menu', () => ({
+ DropdownMenu: function DropdownMenu(props: { children?: unknown }) {
+ return { type: 'DropdownMenu', props }
+ },
+ DropdownMenuContent: function DropdownMenuContent(props: { children?: unknown }) {
+ return { type: 'DropdownMenuContent', props }
+ },
+ DropdownMenuItem: function DropdownMenuItem(props: { children?: unknown }) {
+ return { type: 'DropdownMenuItem', props }
+ },
+ DropdownMenuSeparator: function DropdownMenuSeparator() {
+ return { type: 'DropdownMenuSeparator', props: {} }
+ },
+ DropdownMenuTrigger: function DropdownMenuTrigger(props: { children?: unknown }) {
+ return { type: 'DropdownMenuTrigger', props }
+ }
+}))
+
+vi.mock('@/components/ui/input', () => ({
+ Input: function Input(props: Record) {
+ return { type: 'input', props }
+ }
+}))
+
+vi.mock('@/components/editor/editor-labels', () => ({
+ getEditorDisplayLabel: (file: OpenFile) => file.relativePath
+}))
+
+vi.mock('@/lib/rename-file', () => ({
+ renameFileOnDisk: vi.fn()
+}))
+
+vi.mock('@/lib/file-type-icons', () => ({
+ getFileTypeIcon: () =>
+ function FileIcon(props: Record) {
+ return { type: 'FileIcon', props }
+ }
+}))
+
+vi.mock('@/store/selectors', () => ({
+ useRepoById: () => ({ connectionId: null }),
+ useWorktreeById: () => ({ path: '/repo', repoId: 'repo-1' })
+}))
+
+vi.mock('@/store', () => {
+ const useAppStore = (selector: (state: { openMarkdownPreview: typeof vi.fn }) => unknown) =>
+ selector({ openMarkdownPreview: appStoreMocks.openMarkdownPreview })
+ useAppStore.getState = appStoreMocks.getState
+ return { useAppStore }
+})
+
+vi.mock('../right-sidebar/status-display', () => ({
+ STATUS_COLORS: {},
+ STATUS_LABELS: {}
+}))
+
+vi.mock('./SortableTab', () => ({
+ CLOSE_ALL_CONTEXT_MENUS_EVENT: 'orca-close-all-context-menus'
+}))
+
+vi.mock('./drop-indicator', () => ({
+ ACTIVE_TAB_INDICATOR_CLASSES: 'active-tab-indicator',
+ getDropIndicatorClasses: () => ''
+}))
+
+vi.mock('@/components/editor/markdown-preview-controls', () => ({
+ canOpenMarkdownPreview: () => false
+}))
+
+vi.mock('@/lib/local-path-open-guard', () => ({
+ shouldBlockEditorTabLocalOpen: () => false,
+ showLocalPathOpenBlockedToast: vi.fn()
+}))
+
+type ReactElementLike = {
+ type: unknown
+ props: Record
+}
+
+function baseFile(overrides: Partial = {}): OpenFile {
+ return {
+ id: '/repo/untitled-5.md',
+ filePath: '/repo/untitled-5.md',
+ relativePath: 'untitled-5.md',
+ worktreeId: 'wt-1',
+ language: 'markdown',
+ isDirty: false,
+ mode: 'edit',
+ ...overrides
+ }
+}
+
+async function renderEditorFileTab(
+ file: OpenFile,
+ onActivate = vi.fn()
+): Promise<{ element: unknown; onActivate: ReturnType }> {
+ reactHookRuntime.index = 0
+ const module = await import('./EditorFileTab')
+ const element = module.default({
+ file,
+ isActive: true,
+ hasTabsToRight: false,
+ statusByRelativePath: new Map(),
+ onActivate,
+ onClose: () => {},
+ onCloseToRight: () => {},
+ onCloseAll: () => {},
+ onPin: () => {},
+ onSplitGroup: () => {},
+ dragData: {
+ kind: 'tab',
+ worktreeId: file.worktreeId,
+ groupId: 'group-1',
+ unifiedTabId: file.id,
+ visibleTabId: file.id,
+ tabType: 'editor',
+ label: file.relativePath,
+ iconPath: file.filePath
+ }
+ })
+ return { element, onActivate }
+}
+
+function expandNode(node: unknown): unknown {
+ if (node == null || typeof node === 'string' || typeof node === 'number') {
+ return node
+ }
+ if (Array.isArray(node)) {
+ return node.map(expandNode)
+ }
+ const el = node as ReactElementLike
+ if (typeof el.type === 'function') {
+ return expandNode(el.type(el.props))
+ }
+ return {
+ ...el,
+ props: {
+ ...el.props,
+ children: expandNode(el.props?.children)
+ }
+ }
+}
+
+function getText(node: unknown): string {
+ if (node == null) {
+ return ''
+ }
+ if (typeof node === 'string' || typeof node === 'number') {
+ return String(node)
+ }
+ if (Array.isArray(node)) {
+ return node.map(getText).join('')
+ }
+ const el = node as ReactElementLike
+ return getText(el.props?.children)
+}
+
+function findElementsByType(node: unknown, typeName: string): ReactElementLike[] {
+ const results: ReactElementLike[] = []
+ const visit = (current: unknown): void => {
+ if (current == null || typeof current === 'string' || typeof current === 'number') {
+ return
+ }
+ if (Array.isArray(current)) {
+ for (const child of current) {
+ visit(child)
+ }
+ return
+ }
+ const el = current as ReactElementLike
+ if (el.type === typeName) {
+ results.push(el)
+ }
+ visit(el.props?.children)
+ }
+ visit(node)
+ return results
+}
+
+function findMenuItemByText(node: unknown, label: string): ReactElementLike {
+ const item = findElementsByType(node, 'DropdownMenuItem').find((candidate) =>
+ getText(candidate).includes(label)
+ )
+ if (!item) {
+ throw new Error(`Missing menu item: ${label}`)
+ }
+ return item
+}
+
+describe('EditorFileTab rename menu', () => {
+ beforeEach(() => {
+ reactHookRuntime.states = []
+ reactHookRuntime.index = 0
+ vi.clearAllMocks()
+ vi.resetModules()
+ vi.stubGlobal('navigator', { userAgent: 'Mac' })
+ })
+
+ it('turns the tab filename into an inline input from the Rename context-menu item', async () => {
+ const onActivate = vi.fn()
+ const file = baseFile({ isUntitled: true })
+ const firstRender = expandNode((await renderEditorFileTab(file, onActivate)).element)
+ const renameItem = findMenuItemByText(firstRender, 'Rename')
+
+ // Why: New Markdown tabs are real on-disk files even while marked
+ // isUntitled; the tab menu must let users rename the screenshot-style
+ // "untitled-N.md" files directly.
+ expect(renameItem.props.disabled).toBe(false)
+ ;(renameItem.props.onSelect as () => void)()
+
+ const secondRender = expandNode((await renderEditorFileTab(file, onActivate)).element)
+ const inputs = findElementsByType(secondRender, 'input')
+ expect(inputs).toHaveLength(1)
+ expect(inputs[0].props.defaultValue).toBe('untitled-5.md')
+ expect(inputs[0].props['data-tab-rename-input']).toBe('true')
+ expect(onActivate).toHaveBeenCalledTimes(1)
+ })
+
+ it('disables Rename for diff tabs that do not map to one writable file', async () => {
+ const file = baseFile({
+ mode: 'diff',
+ diffSource: 'unstaged'
+ })
+ const element = expandNode((await renderEditorFileTab(file)).element)
+ const renameItem = findMenuItemByText(element, 'Rename')
+
+ expect(renameItem.props.disabled).toBe(true)
+ })
+})
diff --git a/src/renderer/src/components/tab-bar/EditorFileTab.tsx b/src/renderer/src/components/tab-bar/EditorFileTab.tsx
index e3a82a36b..258573674 100644
--- a/src/renderer/src/components/tab-bar/EditorFileTab.tsx
+++ b/src/renderer/src/components/tab-bar/EditorFileTab.tsx
@@ -9,7 +9,8 @@ import {
ShieldAlert,
ExternalLink,
Columns2,
- Rows2
+ Rows2,
+ Pencil
} from 'lucide-react'
import {
DropdownMenu,
@@ -18,6 +19,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
+import { Input } from '@/components/ui/input'
import { basename, normalizeRelativePath } from '@/lib/path'
import { getEditorDisplayLabel } from '@/components/editor/editor-labels'
import { renameFileOnDisk } from '@/lib/rename-file'
@@ -28,6 +30,7 @@ import { useAppStore } from '@/store'
import { STATUS_COLORS, STATUS_LABELS } from '../right-sidebar/status-display'
import type { GitFileStatus } from '../../../../shared/types'
import type { OpenFile } from '../../store/slices/editor'
+import { getUntitledFileRoot } from '@/components/editor/untitled-file-rename-path'
import { preventMiddleButtonDefault } from './middle-button-default-guard'
import { CLOSE_ALL_CONTEXT_MENUS_EVENT } from './SortableTab'
import type { TabDragItemData } from '../tab-group/useTabDragSplit'
@@ -109,15 +112,23 @@ export default function EditorFileTab({
const [menuPoint, setMenuPoint] = useState({ x: 0, y: 0 })
const [isRenaming, setIsRenaming] = useState(false)
const renameInputRef = useRef(null)
+ const skipMenuFocusRestoreRef = useRef(false)
// Escape fires setIsRenaming(false), which unmounts the input. The browser
// still fires focusout as the focused node is removed, so onBlur can invoke
// commitRename *after* cancel — committing the typed value against the
// user's intent. This flag suppresses the trailing blur-commit.
const renameCancelledRef = useRef(false)
- // Only real on-disk files in edit mode are renameable. Diff, conflict-review,
- // untitled drafts, and combined/virtual views don't point at a single concrete
- // file we can safely rename.
- const canRename = file.mode === 'edit' && !file.isUntitled && !file.diffSource && !file.conflict
+ // Only on-disk edit tabs are renameable. Diff, conflict-review, and
+ // combined/virtual views don't point at a single concrete file we can safely rename.
+ const canRename = file.mode === 'edit' && !file.diffSource && !file.conflict
+
+ const openRenameInput = (): void => {
+ if (!canRename) {
+ return
+ }
+ renameCancelledRef.current = false
+ setIsRenaming(true)
+ }
const commitRename = (): void => {
if (renameCancelledRef.current) {
@@ -139,10 +150,7 @@ export default function EditorFileTab({
if (newName === oldName) {
return
}
- const worktreePath = worktree?.path ?? null
- if (!worktreePath) {
- return
- }
+ const worktreePath = getUntitledFileRoot(file, worktree?.path ?? null)
void renameFileOnDisk({
oldPath: file.filePath,
newName,
@@ -261,12 +269,15 @@ export default function EditorFileTab({
)}
{isRenaming ? (
- e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
@@ -297,7 +308,7 @@ export default function EditorFileTab({
return
}
e.stopPropagation()
- setIsRenaming(true)
+ openRenameInput()
}}
>
{getEditorDisplayLabel(file)}
@@ -353,7 +364,18 @@ export default function EditorFileTab({
style={{ left: menuPoint.x, top: menuPoint.y }}
/>
-
+ {
+ if (!skipMenuFocusRestoreRef.current) {
+ return
+ }
+ skipMenuFocusRestoreRef.current = false
+ event.preventDefault()
+ }}
+ >
onSplitGroup('up', file.tabId ?? file.id)}>
Split Up
@@ -371,6 +393,18 @@ export default function EditorFileTab({
Split Right
+ {
+ skipMenuFocusRestoreRef.current = true
+ onActivate()
+ openRenameInput()
+ }}
+ >
+
+ Rename
+
+
Close
Close All Editor Tabs
diff --git a/src/renderer/src/lib/remap-open-editor-tabs-for-path-change.test.ts b/src/renderer/src/lib/remap-open-editor-tabs-for-path-change.test.ts
index cab27a65f..d64166635 100644
--- a/src/renderer/src/lib/remap-open-editor-tabs-for-path-change.test.ts
+++ b/src/renderer/src/lib/remap-open-editor-tabs-for-path-change.test.ts
@@ -163,4 +163,33 @@ describe('remapOpenEditorTabsForPathChange', () => {
markdownPreviewSourceFileId: floatingNewSourceId
})
})
+
+ it('clears the untitled marker when remapping a renamed new markdown file', () => {
+ const state = useAppStore.getState()
+ const oldPath = '/repo/untitled.md'
+ const newPath = '/repo/renamed.md'
+
+ state.openFile({
+ filePath: oldPath,
+ relativePath: 'untitled.md',
+ worktreeId: 'wt-1',
+ language: 'markdown',
+ isUntitled: true,
+ mode: 'edit'
+ })
+
+ remapOpenEditorTabsForPathChange({
+ fromPath: oldPath,
+ toPath: newPath,
+ worktreePath: '/repo',
+ worktreeId: 'wt-1'
+ })
+
+ expect(useAppStore.getState().openFiles).toHaveLength(1)
+ expect(useAppStore.getState().openFiles[0]).toMatchObject({
+ filePath: newPath,
+ relativePath: 'renamed.md'
+ })
+ expect(useAppStore.getState().openFiles[0].isUntitled).toBeUndefined()
+ })
})
diff --git a/src/renderer/src/lib/remap-open-editor-tabs-for-path-change.ts b/src/renderer/src/lib/remap-open-editor-tabs-for-path-change.ts
index b119fbc00..5f354f06b 100644
--- a/src/renderer/src/lib/remap-open-editor-tabs-for-path-change.ts
+++ b/src/renderer/src/lib/remap-open-editor-tabs-for-path-change.ts
@@ -160,6 +160,12 @@ export function remapOpenEditorTabsForPathChange({
const draft = state.editorDrafts[file.id]
const wasDirty = file.isDirty
+ // Why: renameRuntimePath already moved the file. Clear the untitled marker
+ // before closeFile so its cleanup path does not try to delete the old path.
+ if (file.isUntitled) {
+ useAppStore.getState().clearUntitled(file.id)
+ }
+
// Why: preview tabs use synthetic ids (`markdown-preview::...`) instead of
// filePath, so close the real tab id before reopening at the new path.
state.closeFile(file.id)