Merge remote-tracking branch 'origin/main' into feat/changes-md-reading
# Conflicts: # src/client/MarkdownHtml.tsx
This commit is contained in:
@@ -40,6 +40,7 @@ import { TreePanel } from './TreePanel.tsx'
|
||||
import { t } from './locales.ts'
|
||||
import { relativeTo } from './paths.ts'
|
||||
import { resolveSidebarPath } from './produced-files.ts'
|
||||
import { closePathTabs, retargetPathTabs } from './tree-mutations.ts'
|
||||
import type { EditorToolbarControls, EditorToolbarState, FileViewerDescriptor } from './service.ts'
|
||||
import { firstLeaf, insertLeafAt, leafWithTab, mintTabId, treeOf, type SidebarStore, type SidebarTab } from './state.ts'
|
||||
import css from './sidebar.module.css'
|
||||
@@ -225,6 +226,16 @@ export function EditorHost(props: {
|
||||
})
|
||||
}
|
||||
|
||||
// Tree mutations reconcile the OPEN tabs (both split trees, the bottom
|
||||
// panel, free windows): a rename retargets its tab to the new path; a
|
||||
// delete closes tabs at or under the removed path. See tree-mutations.ts.
|
||||
const onPathRenamed = (oldPath: string, newPath: string): void => {
|
||||
retargetPathTabs(ctx, store, oldPath, newPath)
|
||||
}
|
||||
const onPathDeleted = (path: string): void => {
|
||||
closePathTabs(ctx, store, path)
|
||||
}
|
||||
|
||||
// The viewer's toolbar, hoisted into THIS header: the text editor reports
|
||||
// its state and registers its commands (both null/absent for viewers
|
||||
// without a toolbar — image, pdf, binary download).
|
||||
@@ -389,6 +400,8 @@ export function EditorHost(props: {
|
||||
onOpenWith={openWith}
|
||||
onToggleOpenWithPin={toggleOpenWithPin}
|
||||
onReferenceFile={onReferenceFile}
|
||||
onPathRenamed={onPathRenamed}
|
||||
onPathDeleted={onPathDeleted}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
@@ -511,6 +524,8 @@ export function EditorHost(props: {
|
||||
onOpenWith={openWith}
|
||||
onToggleOpenWithPin={toggleOpenWithPin}
|
||||
onReferenceFile={onReferenceFile}
|
||||
onPathRenamed={onPathRenamed}
|
||||
onPathDeleted={onPathDeleted}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
+210
-17
@@ -24,14 +24,16 @@ import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type
|
||||
import { createPortal } from 'react-dom'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
IconChevronRightOutline14, IconCodeOutline16, IconCopyOutline16, IconDownloadOutline16,
|
||||
IconLinkOutline16, Menu, type MenuEntry, type MenuItem, writeClipboard,
|
||||
Button, IconChevronRightOutline14, IconCloseFill14, IconCodeOutline16, IconCopyOutline16, IconDownloadOutline16,
|
||||
IconEditOutline16, IconLinkOutline16, IconTrashOutline16, Menu, Modal, type MenuEntry, type MenuItem, writeClipboard,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { SiCursor, SiZedindustries } from 'react-icons/si'
|
||||
import { VscFile, VscFolder, VscFolderOpened, VscLinkExternal, VscPin, VscPinned } from 'react-icons/vsc'
|
||||
import { api, downloadUrl, isOutsideWorkspaceMessage, type FsEntry } from './api.ts'
|
||||
import { FenceErrorNotice } from './FenceErrorNotice.tsx'
|
||||
import { IconUploadOutline16, IconVscode16 } from './icons.tsx'
|
||||
import { isImeComposition } from './ime-guard.ts'
|
||||
import { useSubmenuFlip } from './menu-flip.ts'
|
||||
import type { OpenWithTarget } from './open-with.ts'
|
||||
import { relativeTo } from './paths.ts'
|
||||
import { t } from './locales.ts'
|
||||
@@ -133,6 +135,10 @@ export function FileTree(props: {
|
||||
onToggleOpenWithPin?: (targetId: string) => void
|
||||
/** Insert `@<relative path>` into the composer draft (file vs directory). */
|
||||
onReferenceFile: (path: string, isDir: boolean) => void
|
||||
/** A rename landed (old row path → new path): the caller retargets open tabs. */
|
||||
onPathRenamed?: (oldPath: string, newPath: string) => void
|
||||
/** A delete landed: the caller closes tabs at or under the removed path. */
|
||||
onPathDeleted?: (path: string, isDir: boolean) => void
|
||||
/** Bump to wipe the level cache and reload the visible set. */
|
||||
refreshTick: number
|
||||
/** Upload into `dir` (absolute, inside the workspace); runs in the caller. */
|
||||
@@ -140,13 +146,22 @@ export function FileTree(props: {
|
||||
/** True while an upload is in flight (drops are ignored). */
|
||||
busy: boolean
|
||||
}) {
|
||||
const { sessionId, cwd, store, expanded, revealed, onToggle, onOpenFile, onOpenFileNewTab, onOpenFileSide, openWithTargets, openWithPinned, openWithSsh, onOpenWith, onToggleOpenWithPin, onReferenceFile, refreshTick, onUploadRequest, busy } = props
|
||||
const { sessionId, cwd, store, expanded, revealed, onToggle, onOpenFile, onOpenFileNewTab, onOpenFileSide, openWithTargets, openWithPinned, openWithSsh, onOpenWith, onToggleOpenWithPin, onReferenceFile, onPathRenamed, onPathDeleted, refreshTick, onUploadRequest, busy } = props
|
||||
const [data, setData] = useState<Record<string, LevelData>>({})
|
||||
const dataRef = useRef(data)
|
||||
/** The row whose path was just copied ("copied" label replaces its button). */
|
||||
const [copiedPath, setCopiedPath] = useState<string | null>(null)
|
||||
/** Open context menu: the row path (and whether it is a directory) plus the cursor position. */
|
||||
const [rowMenu, setRowMenu] = useState<{ path: string; isDir: boolean; x: number; y: number } | null>(null)
|
||||
// The row menu's "open with" submenu is the one submenu that can tower past
|
||||
// the viewport; publish its flip geometry for layout.css while it is open.
|
||||
useSubmenuFlip(rowMenu)
|
||||
/** The row being renamed inline: its path plus the edit buffer. */
|
||||
const [renaming, setRenaming] = useState<{ path: string; value: string } | null>(null)
|
||||
/** The delete awaiting the confirmation modal's yes. */
|
||||
const [confirmDelete, setConfirmDelete] = useState<{ path: string; isDir: boolean; name: string } | null>(null)
|
||||
/** The last mutation failure (dismissable strip above the tree). */
|
||||
const [actionError, setActionError] = useState<string | null>(null)
|
||||
/** Whether a file drag hovers the tree (drives the portaled drop zone). */
|
||||
const [dropOver, setDropOver] = useState(false)
|
||||
/** The directory a drag is hovering right now (null = body, drop to root). */
|
||||
@@ -268,6 +283,63 @@ export function FileTree(props: {
|
||||
loadDir(dir)
|
||||
}, [loadDir])
|
||||
|
||||
/**
|
||||
* Settle the tree after one rename/delete landed at `prefix`: drop every
|
||||
* cached level at or under the old path, collapse the now-stale expanded
|
||||
* directories below it, and reload the immediate parent so the new shape
|
||||
* shows without a full refresh tick.
|
||||
*/
|
||||
const pruneTree = (prefix: string): void => {
|
||||
const parent = parentOf(prefix)
|
||||
const under = (key: string): boolean => key === prefix || key.startsWith(`${prefix}/`) || key.startsWith(`${prefix}\\`)
|
||||
for (const key of Object.keys(dataRef.current)) {
|
||||
if (under(key)) delete dataRef.current[key]
|
||||
}
|
||||
if (parent !== prefix) delete dataRef.current[parent]
|
||||
setData({ ...dataRef.current })
|
||||
for (const dir of expanded) {
|
||||
if (under(dir)) onToggle(dir)
|
||||
}
|
||||
if (parent !== prefix) retryDir(parent)
|
||||
}
|
||||
|
||||
/** Client-side twin of the server's name rule (the server re-validates). */
|
||||
const validRename = (name: string): boolean =>
|
||||
name !== '' && name !== '.' && name !== '..' && !name.includes('/') && !name.includes('\\')
|
||||
|
||||
/** Commit the inline rename: trim, no-op guard, then fs.rename + settle. */
|
||||
const commitRename = (path: string, raw: string): void => {
|
||||
setRenaming(null)
|
||||
const name = raw.trim()
|
||||
if (cwd === undefined || name === baseName(path) || !validRename(name)) {
|
||||
if (name !== baseName(path) && !validRename(name)) setActionError(t('renameInvalid'))
|
||||
return
|
||||
}
|
||||
api.fsRename({ sessionId, cwd }, path, name)
|
||||
.then((result) => {
|
||||
setActionError(null)
|
||||
pruneTree(path)
|
||||
onPathRenamed?.(path, result.path)
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
setActionError(error instanceof Error ? error.message : String(error))
|
||||
})
|
||||
}
|
||||
|
||||
/** Run the confirmed delete: fs.remove + settle + close affected tabs. */
|
||||
const performDelete = (target: { path: string; isDir: boolean }): void => {
|
||||
if (cwd === undefined) return
|
||||
api.fsRemove({ sessionId, cwd }, target.path)
|
||||
.then(() => {
|
||||
setActionError(null)
|
||||
pruneTree(target.path)
|
||||
onPathDeleted?.(target.path, target.isDir)
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
setActionError(error instanceof Error ? error.message : String(error))
|
||||
})
|
||||
}
|
||||
|
||||
// The caller's refresh tick wipes the cache (declared BEFORE the load
|
||||
// effect so the reload below sees the empty cache).
|
||||
const lastTick = useRef(refreshTick)
|
||||
@@ -373,13 +445,14 @@ export function FileTree(props: {
|
||||
if (openWithTargets === undefined || onOpenWith === undefined || openWithTargets.length === 0) return []
|
||||
const pinnedIds = openWithPinned ?? []
|
||||
/** Brand marks for the built-ins (monochrome silhouettes, currentColor);
|
||||
* reveal gets the folder glyph, custom editors a generic code mark. */
|
||||
* reveal gets the folder glyph, custom editors a generic code mark.
|
||||
* The compact menu's icon slot is 14px, so every mark renders at 14. */
|
||||
const itemIcon = (target: OpenWithTarget): ReactNode => {
|
||||
if (target.kind === 'reveal') return <VscFolderOpened size={16} />
|
||||
if (target.id === 'vscode') return <IconVscode16 size={16} />
|
||||
if (target.id === 'cursor') return <SiCursor size={16} />
|
||||
if (target.id === 'zed') return <SiZedindustries size={16} />
|
||||
return <IconCodeOutline16 size={16} />
|
||||
if (target.kind === 'reveal') return <VscFolderOpened size={14} />
|
||||
if (target.id === 'vscode') return <IconVscode16 size={14} />
|
||||
if (target.id === 'cursor') return <SiCursor size={14} />
|
||||
if (target.id === 'zed') return <SiZedindustries size={14} />
|
||||
return <IconCodeOutline16 size={14} />
|
||||
}
|
||||
const pinned = openWithTargets
|
||||
.filter(target => pinnedIds.includes(target.id))
|
||||
@@ -412,7 +485,7 @@ export function FileTree(props: {
|
||||
onToggleOpenWithPin?.(target.id)
|
||||
}}
|
||||
>
|
||||
{pinnedNow ? <VscPinned size={14} /> : <VscPin size={14} />}
|
||||
{pinnedNow ? <VscPinned size={12} /> : <VscPin size={12} />}
|
||||
</span>
|
||||
</span>
|
||||
),
|
||||
@@ -433,7 +506,7 @@ export function FileTree(props: {
|
||||
<IconChevronRightOutline14 size={14} className={css.openWithChevron} aria-hidden />
|
||||
</span>
|
||||
),
|
||||
icon: <VscLinkExternal size={16} />,
|
||||
icon: <VscLinkExternal size={14} />,
|
||||
submenu,
|
||||
},
|
||||
]
|
||||
@@ -447,6 +520,58 @@ export function FileTree(props: {
|
||||
const expandedSet = useMemo(() => new Set(expanded), [expanded])
|
||||
const revealedSet = useMemo(() => new Set(revealed), [revealed])
|
||||
|
||||
/**
|
||||
* Focus + select the rename editor once, on mount. The callback identity
|
||||
* must stay STABLE (useCallback []): an inline arrow re-runs on every
|
||||
* render (detach null → attach el), and focus()/select() mid-keystroke
|
||||
* would reselect the whole buffer while typing. A plain <input>, not the
|
||||
* primitives Input — that one forwards no ref, and focus+select is the
|
||||
* entire point here.
|
||||
*/
|
||||
const renameInputRef = useCallback((el: HTMLInputElement | null): void => {
|
||||
if (el !== null) {
|
||||
el.focus()
|
||||
el.select()
|
||||
}
|
||||
}, [])
|
||||
|
||||
/** The inline rename editor replacing one row (dirs and files alike):
|
||||
* same indent/icon/height for a seamless swap, Enter/blur commits,
|
||||
* Escape cancels, IME composition keys never reach the handlers (the
|
||||
* shared isImeComposition guard). */
|
||||
const renderRenameRow = (entry: FsEntry, depth: number): ReactNode => (
|
||||
<div
|
||||
key={entry.path}
|
||||
className={clsx(css.explorerRow, css.explorerRenaming)}
|
||||
style={{ paddingLeft: depth * 22 + 6 }}
|
||||
>
|
||||
{entry.isDir
|
||||
? (expandedSet.has(entry.path) ? <VscFolderOpened size={14} /> : <VscFolder size={14} />)
|
||||
: <VscFile size={14} />}
|
||||
<input
|
||||
ref={renameInputRef}
|
||||
className={css.explorerRenameInput}
|
||||
value={renaming?.value ?? ''}
|
||||
aria-label={t('rename')}
|
||||
spellCheck={false}
|
||||
onChange={(event) => {
|
||||
setRenaming(prev => prev === null ? prev : { ...prev, value: event.target.value })
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (isImeComposition(event)) return
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
commitRename(entry.path, renaming?.value ?? '')
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
setRenaming(null)
|
||||
}
|
||||
}}
|
||||
onBlur={() => { commitRename(entry.path, renaming?.value ?? '') }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
const renderLevel = (dir: string, depth: number): ReactNode => {
|
||||
const level = data[dir]
|
||||
if (level === undefined) {
|
||||
@@ -471,6 +596,10 @@ export function FileTree(props: {
|
||||
}
|
||||
const entries = level.entries ?? []
|
||||
return entries.map(entry => {
|
||||
// The row being renamed renders as its editor (no button semantics:
|
||||
// an editor is not a click target — and a nested interactive inside
|
||||
// role="button" would be invalid anyway).
|
||||
if (renaming?.path === entry.path) return renderRenameRow(entry, depth)
|
||||
if (entry.isDir) {
|
||||
const isOpen = expandedSet.has(entry.path)
|
||||
return (
|
||||
@@ -551,6 +680,22 @@ export function FileTree(props: {
|
||||
<div className={css.explorerEmpty}>{t('noSession')}</div>
|
||||
) : (
|
||||
<>
|
||||
{/* The last mutation failure (rename/delete): dismissable, raw
|
||||
server text — the same show-the-truth policy the fence notice
|
||||
uses. Any later action or a fresh attempt clears it. */}
|
||||
{actionError !== null && (
|
||||
<div className={css.explorerActionError} role="alert">
|
||||
<span className={css.explorerActionErrorText}>{actionError}</span>
|
||||
<button
|
||||
type="button"
|
||||
className={css.explorerActionErrorClose}
|
||||
aria-label={t('dismiss')}
|
||||
onClick={() => { setActionError(null) }}
|
||||
>
|
||||
<IconCloseFill14 />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={clsx(css.explorerRow, dropTarget === root && css.explorerRowDropTarget)}
|
||||
style={{ paddingLeft: 6 }}
|
||||
@@ -650,22 +795,31 @@ export function FileTree(props: {
|
||||
items={[
|
||||
// The open escapes head the FILE menu (dirs only get copy).
|
||||
...(rowMenu?.isDir === false && onOpenFileNewTab !== undefined
|
||||
? [{ id: 'open-new-tab', label: t('openFileNewTab'), icon: <IconCodeOutline16 size={16} /> }]
|
||||
? [{ id: 'open-new-tab', label: t('openFileNewTab'), icon: <IconCodeOutline16 size={14} /> }]
|
||||
: []),
|
||||
...(rowMenu?.isDir === false && onOpenFileSide !== undefined
|
||||
? [{ id: 'open-side', label: t('openFileSide'), icon: <VscFolderOpened size={16} /> }]
|
||||
? [{ id: 'open-side', label: t('openFileSide'), icon: <VscFolderOpened size={14} /> }]
|
||||
: []),
|
||||
...openWithEntries(),
|
||||
// Download applies to files only (the host route refuses directories).
|
||||
...(rowMenu?.isDir === false
|
||||
? [{ id: 'download', label: t('download'), icon: <IconDownloadOutline16 size={16} /> }]
|
||||
? [{ id: 'download', label: t('download'), icon: <IconDownloadOutline16 size={14} /> }]
|
||||
: []),
|
||||
// Upload into a directory (incl. the workspace root row).
|
||||
...(rowMenu?.isDir === true
|
||||
? [{ id: 'upload-here', label: t('uploadHere'), icon: <IconUploadOutline16 size={16} /> }]
|
||||
? [{ id: 'upload-here', label: t('uploadHere'), icon: <IconUploadOutline16 size={14} /> }]
|
||||
: []),
|
||||
{ id: 'relative', label: t('copyRelative'), icon: <IconCopyOutline16 size={14} /> },
|
||||
{ id: 'absolute', label: t('copyAbsolute'), icon: <IconCopyOutline16 size={14} /> },
|
||||
// Explorer mutations close the menu; the workspace ROOT row is the
|
||||
// session itself — never renamable or deletable (server double-guards).
|
||||
...(rowMenu !== null && rowMenu.path !== cwd
|
||||
? [
|
||||
{ id: 'mutate-sep', type: 'separator' } as MenuEntry,
|
||||
{ id: 'rename', label: t('rename'), icon: <IconEditOutline16 size={14} /> },
|
||||
{ id: 'delete', label: t('delete'), icon: <IconTrashOutline16 size={14} />, danger: true },
|
||||
]
|
||||
: []),
|
||||
{ id: 'relative', label: t('copyRelative'), icon: <IconCopyOutline16 size={16} /> },
|
||||
{ id: 'absolute', label: t('copyAbsolute'), icon: <IconCopyOutline16 size={16} /> },
|
||||
]}
|
||||
onSelect={(id) => {
|
||||
const target = rowMenu
|
||||
@@ -692,16 +846,55 @@ export function FileTree(props: {
|
||||
fileInputRef.current?.click()
|
||||
return
|
||||
}
|
||||
if (id === 'rename') {
|
||||
setRenaming({ path: target.path, value: baseName(target.path) })
|
||||
return
|
||||
}
|
||||
if (id === 'delete') {
|
||||
setConfirmDelete({ path: target.path, isDir: target.isDir, name: baseName(target.path) })
|
||||
return
|
||||
}
|
||||
copyPath(
|
||||
id === 'relative' ? relativeTo(cwd ?? '', target.path) : target.path,
|
||||
target.path,
|
||||
)
|
||||
}}
|
||||
portal
|
||||
compact
|
||||
align="start"
|
||||
getAnchorRect={() => (rowMenu === null ? null : new DOMRect(rowMenu.x, rowMenu.y, 0, 0))}
|
||||
anchor={<span />}
|
||||
/>
|
||||
|
||||
{/* The delete confirmation: destructive and permanent (no host trash),
|
||||
so it always lands here first — the same Cancel/Confirm shape the
|
||||
git lens uses for discard/revert/cherry-pick. */}
|
||||
<Modal
|
||||
open={confirmDelete !== null}
|
||||
onClose={() => { setConfirmDelete(null) }}
|
||||
title={confirmDelete === null ? '' : t('deleteTitle', { name: confirmDelete.name })}
|
||||
closeLabel={t('cancel')}
|
||||
footer={(
|
||||
<>
|
||||
<Button variant="outline" onClick={() => { setConfirmDelete(null) }}>{t('cancel')}</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
const pending = confirmDelete
|
||||
if (pending === null) return
|
||||
setConfirmDelete(null)
|
||||
performDelete(pending)
|
||||
}}
|
||||
>
|
||||
{t('delete')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<p className={css.explorerConfirmDesc}>
|
||||
{confirmDelete === null ? '' : t(confirmDelete.isDir ? 'deleteDescDir' : 'deleteDescFile')}
|
||||
</p>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -279,6 +279,7 @@ export function FreeWindow(props: {
|
||||
else if (id === 'close') onClose()
|
||||
}}
|
||||
portal
|
||||
compact
|
||||
align="start"
|
||||
getAnchorRect={() => (menu === null ? null : new DOMRect(menu.x, menu.y, 0, 0))}
|
||||
anchor={<span />}
|
||||
|
||||
@@ -10,16 +10,18 @@
|
||||
* Security posture: every HTML string (block leaves, inline text, wrapper
|
||||
* open-tag attributes) goes through DOMPurify with an explicit denylist on
|
||||
* top of its defaults (no script/style/iframe/forms), anchors are forced to
|
||||
* open in a new tab with noopener, and local media `src` attributes are
|
||||
* rewritten through the session-scoped `/sidebar/file` media route — the same
|
||||
* trust fence the markdown image rewriter (`markdown-images.ts`) uses.
|
||||
* open in a new tab with noopener, and local media goes through the
|
||||
* session-scoped `/sidebar/file` media route — markdown-syntax image
|
||||
* destinations are rewritten via `markdown-images.ts` and `src` attributes
|
||||
* through `resolveLocalMediaDest` (the same trust fence).
|
||||
*/
|
||||
import { useLayoutEffect, useMemo, useRef } from 'react'
|
||||
import { createElement, type ReactNode } from 'react'
|
||||
import DOMPurify from 'dompurify'
|
||||
import { MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { markdownTextProps } from './markdown-labels.tsx'
|
||||
import { resolveLocalMediaDest } from './markdown-images.ts'
|
||||
import { lazyChunkComponent } from './lazy-chunk.tsx'
|
||||
import { resolveLocalMediaDest, rewriteLocalImageUrls } from './markdown-images.ts'
|
||||
import {
|
||||
analyzeHtmlSegment,
|
||||
type AnalyzedMarkdownHtml,
|
||||
@@ -233,7 +235,12 @@ export function MarkdownDocument({ info, media, codeLabels }: MarkdownDocumentPr
|
||||
const prepared = useMemo<PreparedSegment[]>(() => info.segments.map((segment): PreparedSegment => {
|
||||
if (segment.kind === 'markdown') {
|
||||
const defs = info.referenceDefinitions
|
||||
const text = defs === '' ? segment.text : `${segment.text}\n\n${defs}`
|
||||
const raw = defs === '' ? segment.text : `${segment.text}\n\n${defs}`
|
||||
// MarkdownText drops non-http(s) image destinations (chat-security
|
||||
// stance), so rewrite local ones into /sidebar/file media URLs first —
|
||||
// the same trust fence the sanitized HTML leaves below go through.
|
||||
// Idempotent: already-absolute media URLs pass through untouched.
|
||||
const text = rewriteLocalImageUrls(raw, media.scope, media.path, media.origin)
|
||||
return {
|
||||
kind: 'markdown',
|
||||
text,
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import type { SidebarTab } from './state.ts'
|
||||
import { isAgentTabId } from './state.ts'
|
||||
import { isPinnedVirtualTab } from './pinned.ts'
|
||||
import { useSubmenuFlip } from './menu-flip.ts'
|
||||
import { IconPinOutline16 } from './icons.tsx'
|
||||
import { t } from './locales.ts'
|
||||
import css from './sidebar.module.css'
|
||||
@@ -90,6 +91,9 @@ export function TabBar(props: {
|
||||
// The tab right-click context menu: the target tab plus the cursor
|
||||
// position (the portaled Menu anchors there, following the git lens/FileTree).
|
||||
const [tabMenu, setTabMenu] = useState<{ tabId: string; x: number; y: number } | null>(null)
|
||||
// The strip sits at the panel's top, so the pin submenu must grow downward;
|
||||
// the flip hook derives that from the cursor y (upper half → "down").
|
||||
useSubmenuFlip(tabMenu)
|
||||
const [dragOver, setDragOver] = useState(false)
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
// The context target's index in the render-time tab snapshot; -1 when the
|
||||
@@ -265,6 +269,7 @@ export function TabBar(props: {
|
||||
setMenuOpen(false)
|
||||
}}
|
||||
portal
|
||||
compact
|
||||
align="end"
|
||||
anchor={(
|
||||
<button
|
||||
@@ -355,6 +360,7 @@ export function TabBar(props: {
|
||||
}
|
||||
}}
|
||||
portal
|
||||
compact
|
||||
align="start"
|
||||
getAnchorRect={() => (tabMenu === null ? null : new DOMRect(tabMenu.x, tabMenu.y, 0, 0))}
|
||||
anchor={<span />}
|
||||
|
||||
@@ -338,9 +338,11 @@ export function TextEditor(props: FileViewerProps) {
|
||||
() => (markdown && mode === 'preview' ? splitMermaidBlocks(previewMdText) : []),
|
||||
[markdown, mode, previewMdText],
|
||||
)
|
||||
/** Raw-HTML analysis (block runs lifted out + inline gate). Non-null only
|
||||
* for documents that actually contain HTML — plain markdown keeps the
|
||||
* legacy single-pass render path below, byte-for-byte. */
|
||||
/** Raw-HTML analysis (block runs lifted out + inline gate). Non-null for
|
||||
* every markdown preview, so the render below always takes the split
|
||||
* renderer — its markdown runs rewrite local image destinations internally
|
||||
* (see MarkdownHtml.tsx). The legacy single-pass branches (fed the
|
||||
* pre-rewritten `previewText`) are dead in the current wiring. */
|
||||
const htmlInfo = useMemo(
|
||||
() => (markdown && mode === 'preview' ? analyzeMarkdownHtml(previewMdText) : null),
|
||||
[markdown, mode, previewMdText],
|
||||
|
||||
@@ -63,11 +63,15 @@ export function TreePanel(props: {
|
||||
onOpenWith?: (targetId: string, path: string) => void
|
||||
onToggleOpenWithPin?: (targetId: string) => void
|
||||
onReferenceFile: (path: string, isDir: boolean) => void
|
||||
/** A tree rename landed (passed through to FileTree for tab retargeting). */
|
||||
onPathRenamed?: (oldPath: string, newPath: string) => void
|
||||
/** A tree delete landed (passed through to FileTree for tab closing). */
|
||||
onPathDeleted?: (path: string, isDir: boolean) => void
|
||||
/** Full-window presentation: the panel fills its host instead of docking
|
||||
* at a fixed width. */
|
||||
full?: boolean
|
||||
}) {
|
||||
const { sessionId, cwd, store, expanded, revealed, onToggle, onOpenFile, onOpenFileNewTab, onOpenFileSide, openWithTargets, openWithPinned, openWithSsh, onOpenWith, onToggleOpenWithPin, onReferenceFile, full } = props
|
||||
const { sessionId, cwd, store, expanded, revealed, onToggle, onOpenFile, onOpenFileNewTab, onOpenFileSide, openWithTargets, openWithPinned, openWithSsh, onOpenWith, onToggleOpenWithPin, onReferenceFile, onPathRenamed, onPathDeleted, full } = props
|
||||
const [query, setQuery] = useState('')
|
||||
const [results, setResults] = useState<{ matches: string[]; truncated: boolean } | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
@@ -252,6 +256,8 @@ export function TreePanel(props: {
|
||||
onOpenWith={onOpenWith}
|
||||
onToggleOpenWithPin={onToggleOpenWithPin}
|
||||
onReferenceFile={onReferenceFile}
|
||||
onPathRenamed={onPathRenamed}
|
||||
onPathDeleted={onPathDeleted}
|
||||
refreshTick={refreshTick}
|
||||
onUploadRequest={startUpload}
|
||||
busy={busy}
|
||||
|
||||
@@ -278,6 +278,15 @@ export const api = {
|
||||
call<FsTextResult | FsBinaryResult>('fs.read', scopePayload(scope, { path }), signal),
|
||||
fsWrite: (scope: SessionScope, path: string, content: string) =>
|
||||
call<{ ok: true }>('fs.write', scopePayload(scope, { path, content })),
|
||||
/** Rename one tree row within its directory (single-segment name; the
|
||||
* server refuses existing destinations, the workspace root, and — while
|
||||
* the fence is armed — anything resolving outside the workspace). */
|
||||
fsRename: (scope: SessionScope, path: string, name: string) =>
|
||||
call<{ path: string }>('fs.rename', scopePayload(scope, { path, name })),
|
||||
/** Permanently delete one tree row (recursive for directories; a symlink
|
||||
* row unlinks the link only). The UI confirms before calling this. */
|
||||
fsRemove: (scope: SessionScope, path: string) =>
|
||||
call<{ path: string }>('fs.remove', scopePayload(scope, { path })),
|
||||
/** Upload one file's raw bytes into `dir` (keeps the folder tree via
|
||||
* `relativePath`); the host streams it under the session workspace. */
|
||||
uploadFile: (scope: SessionScope, dir: string, relativePath: string, body: Blob, signal?: AbortSignal) =>
|
||||
|
||||
@@ -671,6 +671,7 @@ export function GitLens(props: GitLensProps) {
|
||||
if (id === 'absolute') copy(resolveSidebarPath(repoRoot ?? selectedWorktree ?? scope.cwd, target.entry.path))
|
||||
}}
|
||||
portal
|
||||
compact
|
||||
align="start"
|
||||
getAnchorRect={() => (fileMenu === null ? null : new DOMRect(fileMenu.x, fileMenu.y, 0, 0))}
|
||||
anchor={<span />}
|
||||
@@ -728,6 +729,7 @@ export function GitLens(props: GitLensProps) {
|
||||
}
|
||||
}}
|
||||
portal
|
||||
compact
|
||||
align="start"
|
||||
getAnchorRect={() => (historyMenu === null ? null : new DOMRect(historyMenu.x, historyMenu.y, 0, 0))}
|
||||
anchor={<span />}
|
||||
|
||||
@@ -25,9 +25,12 @@
|
||||
* legacy signal engines emit without isComposing.
|
||||
*/
|
||||
|
||||
/** The pure decision: is this keyboard event part of an IME composition? */
|
||||
export function isImeComposition(event: { isComposing: boolean; keyCode: number }): boolean {
|
||||
return event.isComposing || event.keyCode === 229
|
||||
/** The pure decision: is this keyboard event part of an IME composition?
|
||||
* `isComposing` is optional on the input: React's synthetic KeyboardEvent
|
||||
* type does not declare it (the DOM event always carries it), and the
|
||||
* keyCode 229 fallback covers exactly those callers. */
|
||||
export function isImeComposition(event: { isComposing?: boolean; keyCode: number }): boolean {
|
||||
return event.isComposing === true || event.keyCode === 229
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -111,3 +111,63 @@ body[data-dsh-sidebar-dragging] #root [data-dsh-center-col],
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Context-menu submenu flip (viewport clamping for the portaled menus).
|
||||
*
|
||||
* The ui-primitives Menu clamps its main portal list into the viewport but
|
||||
* positions nested submenus with static CSS only — anchored at the parent
|
||||
* row's bottom, growing UP and to the RIGHT — with no clamp whatsoever.
|
||||
* Docked on the window's right edge, both defaults are the worst case for
|
||||
* this panel: a tall "open with" submenu escapes past the viewport top when
|
||||
* the row menu opens near the top, and past the right edge on narrow panels
|
||||
* (PANEL_MIN 280 < card 218 + gap + submenu 165).
|
||||
*
|
||||
* menu-flip.ts publishes `data-dsh-sidebar-submenu` on <body> — with the
|
||||
* words "down" and/or "left" — for exactly the lifetime of one of OUR
|
||||
* submenu-bearing menus (FileTree row menu, TabBar tab menu). The tokens
|
||||
* flip the growth direction; the attribute's absence restores the
|
||||
* primitive's own geometry untouched, and host-owned menus are never
|
||||
* affected because they never open while the attribute exists (each side
|
||||
* closes on the other's outside pointerdown).
|
||||
*
|
||||
* No max-height/overflow scroll cap here on purpose: overflow clipping
|
||||
* would cut the submenu's ::before hover bridge (the corridor that keeps
|
||||
* itemWrap's mouseleave from closing the card mid-crossing). */
|
||||
|
||||
/* Visual tether, part 1 — the parent row stays lit while its submenu is
|
||||
* open. The primitive only styles :hover, so once the pointer crossed into
|
||||
* the side card the parent row's fill cleared and the floating card read as
|
||||
* unrelated. The Menu marks the open parent row with aria-expanded="true"
|
||||
* (host-rendered, stable), so the same hover token re-applies for the
|
||||
* submenu's whole lifetime. */
|
||||
body[data-dsh-sidebar-submenu] div[role="menu"] button[aria-expanded="true"] {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
/* Visual tether, part 2 — the side card hugs its parent (10px → 4px gap),
|
||||
* with the ::before hover corridor narrowed to match. Ordered BEFORE the
|
||||
* flip overrides below so their left/right flips still win. */
|
||||
body[data-dsh-sidebar-submenu] div[role="menu"] div[role="menu"] {
|
||||
left: calc(100% + 4px);
|
||||
}
|
||||
|
||||
body[data-dsh-sidebar-submenu] div[role="menu"] div[role="menu"]::before {
|
||||
left: -4px;
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
body[data-dsh-sidebar-submenu~="down"] div[role="menu"] div[role="menu"] {
|
||||
top: -4px;
|
||||
bottom: auto;
|
||||
}
|
||||
|
||||
body[data-dsh-sidebar-submenu~="left"] div[role="menu"] div[role="menu"] {
|
||||
left: auto;
|
||||
right: calc(100% + 4px);
|
||||
}
|
||||
|
||||
/* The hover bridge follows the card to the left side of the parent row. */
|
||||
body[data-dsh-sidebar-submenu~="left"] div[role="menu"] div[role="menu"]::before {
|
||||
left: auto;
|
||||
right: -4px;
|
||||
}
|
||||
|
||||
@@ -203,6 +203,13 @@ export const ar: Record<string, string> = {
|
||||
addToConversation: 'إضافة إلى المحادثة',
|
||||
copyRelative: 'نسخ المسار النسبي',
|
||||
copyAbsolute: 'نسخ المسار المطلق',
|
||||
rename: 'إعادة تسمية',
|
||||
renameInvalid: 'لا يمكن أن يكون الاسم فارغًا أو يحتوي على فواصل مسار.',
|
||||
delete: 'حذف',
|
||||
deleteTitle: 'حذف «{name}»؟',
|
||||
deleteDescFile: 'سيتم حذف هذا الملف نهائيًا. لا يمكن التراجع عن هذا الإجراء.',
|
||||
deleteDescDir: 'سيتم حذف هذا الدليل وكل محتوياته نهائيًا. لا يمكن التراجع عن هذا الإجراء.',
|
||||
dismiss: 'إغلاق',
|
||||
download: 'تنزيل',
|
||||
uploadFiles: 'رفع ملفات',
|
||||
uploadFolder: 'رفع مجلد',
|
||||
|
||||
@@ -188,6 +188,13 @@ export const de: Record<string, string> = {
|
||||
addToConversation: 'Zur Unterhaltung hinzufügen',
|
||||
copyRelative: 'Relativen Pfad kopieren',
|
||||
copyAbsolute: 'Absoluten Pfad kopieren',
|
||||
rename: 'Umbenennen',
|
||||
renameInvalid: 'Der Name darf nicht leer sein und keine Pfadtrenner enthalten.',
|
||||
delete: 'Löschen',
|
||||
deleteTitle: '„{name}“ löschen?',
|
||||
deleteDescFile: 'Dadurch wird die Datei dauerhaft gelöscht. Dies kann nicht rückgängig gemacht werden.',
|
||||
deleteDescDir: 'Dadurch werden das Verzeichnis und dessen gesamter Inhalt dauerhaft gelöscht. Dies kann nicht rückgängig gemacht werden.',
|
||||
dismiss: 'Schließen',
|
||||
download: 'Herunterladen',
|
||||
uploadFiles: 'Dateien hochladen',
|
||||
uploadFolder: 'Ordner hochladen',
|
||||
|
||||
@@ -195,6 +195,13 @@ export const fr: Record<string, string> = {
|
||||
addToConversation: 'Ajouter à la discussion',
|
||||
copyRelative: 'Copier le chemin relatif',
|
||||
copyAbsolute: 'Copier le chemin absolu',
|
||||
rename: 'Renommer',
|
||||
renameInvalid: 'Le nom ne peut pas être vide ni contenir de séparateurs de chemin.',
|
||||
delete: 'Supprimer',
|
||||
deleteTitle: 'Supprimer « {name} » ?',
|
||||
deleteDescFile: 'Ce fichier sera définitivement supprimé. Cette action est irréversible.',
|
||||
deleteDescDir: 'Ce répertoire et tout son contenu seront définitivement supprimés. Cette action est irréversible.',
|
||||
dismiss: 'Fermer',
|
||||
download: 'Télécharger',
|
||||
uploadFiles: 'Importer des fichiers',
|
||||
uploadFolder: 'Importer un dossier',
|
||||
|
||||
@@ -202,6 +202,13 @@ export const hi: Record<string, string> = {
|
||||
addToConversation: 'वार्तालाप में जोड़ें',
|
||||
copyRelative: 'सापेक्ष पथ कॉपी',
|
||||
copyAbsolute: 'निरपेक्ष पथ कॉपी',
|
||||
rename: 'नाम बदलें',
|
||||
renameInvalid: 'नाम खाली नहीं हो सकता और उसमें पथ विभाजक नहीं हो सकते।',
|
||||
delete: 'हटाएँ',
|
||||
deleteTitle: '“{name}” हटाएँ?',
|
||||
deleteDescFile: 'यह फ़ाइल स्थायी रूप से हटा दी जाएगी। इसे पूर्ववत नहीं किया जा सकता।',
|
||||
deleteDescDir: 'यह निर्देशिका और उसकी सारी सामग्री स्थायी रूप से हटा दी जाएगी। इसे पूर्ववत नहीं किया जा सकता।',
|
||||
dismiss: 'बंद करें',
|
||||
download: 'डाउनलोड',
|
||||
uploadFiles: 'फ़ाइलें अपलोड करें',
|
||||
uploadFolder: 'फ़ोल्डर अपलोड करें',
|
||||
|
||||
@@ -200,6 +200,13 @@ export const id: Record<string, string> = {
|
||||
addToConversation: 'Tambahkan ke obrolan',
|
||||
copyRelative: 'Salin path relatif',
|
||||
copyAbsolute: 'Salin path absolut',
|
||||
rename: 'Ganti nama',
|
||||
renameInvalid: 'Nama tidak boleh kosong atau mengandung pemisah jalur.',
|
||||
delete: 'Hapus',
|
||||
deleteTitle: 'Hapus "{name}"?',
|
||||
deleteDescFile: 'Ini menghapus file secara permanen. Tidak dapat dibatalkan.',
|
||||
deleteDescDir: 'Ini menghapus direktori dan seluruh isinya secara permanen. Tidak dapat dibatalkan.',
|
||||
dismiss: 'Tutup',
|
||||
download: 'Unduh',
|
||||
uploadFiles: 'Unggah berkas',
|
||||
uploadFolder: 'Unggah folder',
|
||||
|
||||
@@ -193,6 +193,13 @@ export const it: Record<string, string> = {
|
||||
addToConversation: 'Aggiungi alla conversazione',
|
||||
copyRelative: 'Copia percorso relativo',
|
||||
copyAbsolute: 'Copia percorso assoluto',
|
||||
rename: 'Rinomina',
|
||||
renameInvalid: 'Il nome non può essere vuoto o contenere separatori di percorso.',
|
||||
delete: 'Elimina',
|
||||
deleteTitle: 'Eliminare "{name}"?',
|
||||
deleteDescFile: 'Il file verrà eliminato definitivamente. Questa azione non può essere annullata.',
|
||||
deleteDescDir: 'La directory e tutto il suo contenuto verranno eliminati definitivamente. Questa azione non può essere annullata.',
|
||||
dismiss: 'Chiudi',
|
||||
download: 'Scarica',
|
||||
uploadFiles: 'Carica file',
|
||||
uploadFolder: 'Carica cartella',
|
||||
|
||||
@@ -202,6 +202,13 @@ export const ja: Record<string, string> = {
|
||||
addToConversation: '会話に追加',
|
||||
copyRelative: '相対パスをコピー',
|
||||
copyAbsolute: '絶対パスをコピー',
|
||||
rename: '名前の変更',
|
||||
renameInvalid: '名前は空にできず、パス区切り文字を含められません。',
|
||||
delete: '削除',
|
||||
deleteTitle: '「{name}」を削除しますか?',
|
||||
deleteDescFile: 'このファイルを完全に削除します。元に戻せません。',
|
||||
deleteDescDir: 'このディレクトリと内容物をすべて完全に削除します。元に戻せません。',
|
||||
dismiss: '閉じる',
|
||||
download: 'ダウンロード',
|
||||
uploadFiles: 'ファイルをアップロード',
|
||||
uploadFolder: 'フォルダーをアップロード',
|
||||
|
||||
@@ -194,6 +194,13 @@ export const ko: Record<string, string> = {
|
||||
addToConversation: '대화에 추가',
|
||||
copyRelative: '상대 경로 복사',
|
||||
copyAbsolute: '절대 경로 복사',
|
||||
rename: '이름 바꾸기',
|
||||
renameInvalid: '이름은 비워 둘 수 없으며 경로 구분 기호를 포함할 수 없습니다.',
|
||||
delete: '삭제',
|
||||
deleteTitle: '"{name}"을(를) 삭제할까요?',
|
||||
deleteDescFile: '이 파일이 영구적으로 삭제됩니다. 되돌릴 수 없습니다.',
|
||||
deleteDescDir: '이 디렉터리와 모든 내용이 영구적으로 삭제됩니다. 되돌릴 수 없습니다.',
|
||||
dismiss: '닫기',
|
||||
download: '다운로드',
|
||||
uploadFiles: '파일 업로드',
|
||||
uploadFolder: '폴더 업로드',
|
||||
|
||||
@@ -200,6 +200,13 @@ export const nl: Record<string, string> = {
|
||||
addToConversation: 'Aan conversatie toevoegen',
|
||||
copyRelative: 'Relatief pad kopiëren',
|
||||
copyAbsolute: 'Absoluut pad kopiëren',
|
||||
rename: 'Naam wijzigen',
|
||||
renameInvalid: 'De naam mag niet leeg zijn of padscheidingstekens bevatten.',
|
||||
delete: 'Verwijderen',
|
||||
deleteTitle: '"{name}" verwijderen?',
|
||||
deleteDescFile: 'Dit verwijdert het bestand permanent. Dit kan niet ongedaan worden gemaakt.',
|
||||
deleteDescDir: 'Dit verwijdert de map en alles erin permanent. Dit kan niet ongedaan worden gemaakt.',
|
||||
dismiss: 'Sluiten',
|
||||
download: 'Downloaden',
|
||||
uploadFiles: 'Bestanden uploaden',
|
||||
uploadFolder: 'Map uploaden',
|
||||
|
||||
@@ -204,6 +204,13 @@ export const pl: Record<string, string> = {
|
||||
addToConversation: 'Dodaj do rozmowy',
|
||||
copyRelative: 'Kopiuj ścieżkę względną',
|
||||
copyAbsolute: 'Kopiuj ścieżkę bezwzględną',
|
||||
rename: 'Zmień nazwę',
|
||||
renameInvalid: 'Nazwa nie może być pusta ani zawierać separatorów ścieżki.',
|
||||
delete: 'Usuń',
|
||||
deleteTitle: 'Usunąć „{name}”?',
|
||||
deleteDescFile: 'Spowoduje to trwałe usunięcie pliku. Tej operacji nie można cofnąć.',
|
||||
deleteDescDir: 'Spowoduje to trwałe usunięcie katalogu i całej jego zawartości. Tej operacji nie można cofnąć.',
|
||||
dismiss: 'Zamknij',
|
||||
download: 'Pobierz',
|
||||
uploadFiles: 'Wgraj pliki',
|
||||
uploadFolder: 'Wgraj folder',
|
||||
|
||||
@@ -185,6 +185,13 @@ export const pt: Record<string, string> = {
|
||||
addToConversation: 'Adicionar à conversa',
|
||||
copyRelative: 'Copiar caminho relativo',
|
||||
copyAbsolute: 'Copiar caminho absoluto',
|
||||
rename: 'Renomear',
|
||||
renameInvalid: 'O nome não pode ser vazio nem conter separadores de caminho.',
|
||||
delete: 'Excluir',
|
||||
deleteTitle: 'Excluir "{name}"?',
|
||||
deleteDescFile: 'Isso exclui o arquivo permanentemente. Esta ação não pode ser desfeita.',
|
||||
deleteDescDir: 'Isso exclui o diretório e todo o seu conteúdo permanentemente. Esta ação não pode ser desfeita.',
|
||||
dismiss: 'Fechar',
|
||||
download: 'Baixar',
|
||||
uploadFiles: 'Enviar arquivos',
|
||||
uploadFolder: 'Enviar pasta',
|
||||
|
||||
@@ -198,6 +198,13 @@ export const ru: Record<string, string> = {
|
||||
addToConversation: 'Добавить в диалог',
|
||||
copyRelative: 'Копировать относительный путь',
|
||||
copyAbsolute: 'Копировать абсолютный путь',
|
||||
rename: 'Переименовать',
|
||||
renameInvalid: 'Имя не может быть пустым или содержать разделители пути.',
|
||||
delete: 'Удалить',
|
||||
deleteTitle: 'Удалить «{name}»?',
|
||||
deleteDescFile: 'Файл будет удалён безвозвратно. Это действие нельзя отменить.',
|
||||
deleteDescDir: 'Каталог и всё его содержимое будут удалены безвозвратно. Это действие нельзя отменить.',
|
||||
dismiss: 'Закрыть',
|
||||
download: 'Скачать',
|
||||
uploadFiles: 'Загрузить файлы',
|
||||
uploadFolder: 'Загрузить папку',
|
||||
|
||||
@@ -185,6 +185,13 @@ export const sv: Record<string, string> = {
|
||||
addToConversation: 'Lägg till i konversation',
|
||||
copyRelative: 'Kopiera relativ sökväg',
|
||||
copyAbsolute: 'Kopiera absolut sökväg',
|
||||
rename: 'Byt namn',
|
||||
renameInvalid: 'Namnet får inte vara tomt eller innehålla sökvägsavgränsare.',
|
||||
delete: 'Ta bort',
|
||||
deleteTitle: 'Ta bort "{name}"?',
|
||||
deleteDescFile: 'Detta tar bort filen permanent. Det går inte att ångra.',
|
||||
deleteDescDir: 'Detta tar bort katalogen och allt i den permanent. Det går inte att ångra.',
|
||||
dismiss: 'Stäng',
|
||||
download: 'Ladda ner',
|
||||
uploadFiles: 'Ladda upp filer',
|
||||
uploadFolder: 'Ladda upp mapp',
|
||||
|
||||
@@ -202,6 +202,13 @@ export const th: Record<string, string> = {
|
||||
addToConversation: 'เพิ่มไปยังแชท',
|
||||
copyRelative: 'คัดลอกพาธสัมพัทธ์',
|
||||
copyAbsolute: 'คัดลอกพาธ absolute',
|
||||
rename: 'เปลี่ยนชื่อ',
|
||||
renameInvalid: 'ชื่อต้องไม่ว่างเปล่าและต้องไม่มีตัวคั่นเส้นทาง',
|
||||
delete: 'ลบ',
|
||||
deleteTitle: 'ลบ "{name}" หรือไม่',
|
||||
deleteDescFile: 'การดำเนินการนี้จะลบไฟล์อย่างถาวร และไม่สามารถย้อนกลับได้',
|
||||
deleteDescDir: 'การดำเนินการนี้จะลบไดเรกทอรีและเนื้อหาทั้งหมดอย่างถาวร และไม่สามารถย้อนกลับได้',
|
||||
dismiss: 'ปิด',
|
||||
download: 'ดาวน์โหลด',
|
||||
uploadFiles: 'อัปโหลดไฟล์',
|
||||
uploadFolder: 'อัปโหลดโฟลเดอร์',
|
||||
|
||||
@@ -202,6 +202,13 @@ export const tr: Record<string, string> = {
|
||||
addToConversation: 'Sohbete ekle',
|
||||
copyRelative: 'Göreli yolu kopyala',
|
||||
copyAbsolute: 'Mutlak yolu kopyala',
|
||||
rename: 'Yeniden adlandır',
|
||||
renameInvalid: 'Ad boş olamaz veya yol ayırıcıları içeremez.',
|
||||
delete: 'Sil',
|
||||
deleteTitle: '"{name}" silinsin mi?',
|
||||
deleteDescFile: 'Bu, dosyayı kalıcı olarak siler. Bu işlem geri alınamaz.',
|
||||
deleteDescDir: 'Bu, dizini ve tüm içeriğini kalıcı olarak siler. Bu işlem geri alınamaz.',
|
||||
dismiss: 'Kapat',
|
||||
download: 'İndir',
|
||||
uploadFiles: 'Dosyaları yükle',
|
||||
uploadFolder: 'Klasör yükle',
|
||||
|
||||
@@ -202,6 +202,13 @@ export const vi: Record<string, string> = {
|
||||
addToConversation: 'Thêm vào cuộc trò chuyện',
|
||||
copyRelative: 'Sao chép đường dẫn tương đối',
|
||||
copyAbsolute: 'Sao chép đường dẫn tuyệt đối',
|
||||
rename: 'Đổi tên',
|
||||
renameInvalid: 'Tên không được để trống hoặc chứa dấu phân tách đường dẫn.',
|
||||
delete: 'Xóa',
|
||||
deleteTitle: 'Xóa "{name}"?',
|
||||
deleteDescFile: 'Thao tác này sẽ xóa vĩnh viễn tệp. Không thể hoàn tác.',
|
||||
deleteDescDir: 'Thao tác này sẽ xóa vĩnh viễn thư mục và toàn bộ nội dung của nó. Không thể hoàn tác.',
|
||||
dismiss: 'Đóng',
|
||||
download: 'Tải xuống',
|
||||
uploadFiles: 'Tải tệp lên',
|
||||
uploadFolder: 'Tải thư mục lên',
|
||||
|
||||
@@ -217,6 +217,13 @@ export const zhHK: Record<string, string> = {
|
||||
addToConversation: '新增到對話',
|
||||
copyRelative: '複製相對位址',
|
||||
copyAbsolute: '複製絕對位址',
|
||||
rename: '重新命名',
|
||||
renameInvalid: '名稱不可為空,且不可包含路徑分隔符。',
|
||||
delete: '刪除',
|
||||
deleteTitle: '刪除「{name}」?',
|
||||
deleteDescFile: '將永久刪除該檔案,此操作無法復原。',
|
||||
deleteDescDir: '將永久刪除該目錄及其全部內容,此操作無法復原。',
|
||||
dismiss: '關閉',
|
||||
download: '下載',
|
||||
uploadFiles: '上傳檔案',
|
||||
uploadFolder: '上傳資料夾',
|
||||
|
||||
@@ -217,6 +217,13 @@ export const zhMO: Record<string, string> = {
|
||||
addToConversation: '新增到對話',
|
||||
copyRelative: '複製相對位址',
|
||||
copyAbsolute: '複製絕對位址',
|
||||
rename: '重新命名',
|
||||
renameInvalid: '名稱不可為空,且不可包含路徑分隔符。',
|
||||
delete: '刪除',
|
||||
deleteTitle: '刪除「{name}」?',
|
||||
deleteDescFile: '將永久刪除該檔案,此操作無法復原。',
|
||||
deleteDescDir: '將永久刪除該目錄及其全部內容,此操作無法復原。',
|
||||
dismiss: '關閉',
|
||||
download: '下載',
|
||||
uploadFiles: '上傳檔案',
|
||||
uploadFolder: '上傳資料夾',
|
||||
|
||||
@@ -217,6 +217,13 @@ export const zhTW: Record<string, string> = {
|
||||
addToConversation: '新增到對話',
|
||||
copyRelative: '複製相對位址',
|
||||
copyAbsolute: '複製絕對位址',
|
||||
rename: '重新命名',
|
||||
renameInvalid: '名稱不可為空,且不可包含路徑分隔符。',
|
||||
delete: '刪除',
|
||||
deleteTitle: '刪除「{name}」?',
|
||||
deleteDescFile: '將永久刪除該檔案,此操作無法復原。',
|
||||
deleteDescDir: '將永久刪除該目錄及其全部內容,此操作無法復原。',
|
||||
dismiss: '關閉',
|
||||
download: '下載',
|
||||
uploadFiles: '上傳檔案',
|
||||
uploadFolder: '上傳資料夾',
|
||||
|
||||
@@ -214,6 +214,13 @@ export const zh = {
|
||||
addToConversation: '添加到对话',
|
||||
copyRelative: '复制相对地址',
|
||||
copyAbsolute: '复制绝对地址',
|
||||
rename: '重命名',
|
||||
renameInvalid: '名称不能为空,且不能包含路径分隔符。',
|
||||
delete: '删除',
|
||||
deleteTitle: '删除「{name}」?',
|
||||
deleteDescFile: '将永久删除该文件,此操作不可撤销。',
|
||||
deleteDescDir: '将永久删除该目录及其全部内容,此操作不可撤销。',
|
||||
dismiss: '关闭',
|
||||
download: '下载',
|
||||
uploadFiles: '上传文件',
|
||||
uploadFolder: '上传文件夹',
|
||||
@@ -652,6 +659,13 @@ export const en: Record<keyof typeof zh, string> = {
|
||||
addToConversation: 'Add to conversation',
|
||||
copyRelative: 'Copy relative path',
|
||||
copyAbsolute: 'Copy absolute path',
|
||||
rename: 'Rename',
|
||||
renameInvalid: 'The name cannot be empty or contain path separators.',
|
||||
delete: 'Delete',
|
||||
deleteTitle: 'Delete "{name}"?',
|
||||
deleteDescFile: 'This permanently deletes the file. This cannot be undone.',
|
||||
deleteDescDir: 'This permanently deletes the directory and everything inside it. This cannot be undone.',
|
||||
dismiss: 'Dismiss',
|
||||
download: 'Download',
|
||||
uploadFiles: 'Upload files',
|
||||
uploadFolder: 'Upload folder',
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Submenu flip geometry for the portaled context menus.
|
||||
*
|
||||
* The primitives `Menu` clamps its main list into the viewport (portal mode,
|
||||
* 12px margin) but positions nested submenus with pure CSS — `bottom: -4px`
|
||||
* (grow UP from the parent row) and `left: calc(100% + 10px)` (open RIGHT of
|
||||
* the card) — with no viewport clamp at all. This sidebar lives docked on the
|
||||
* RIGHT edge of the window, so both defaults are the worst case here: a tall
|
||||
* "open with" submenu pokes past the viewport top when the row menu opens
|
||||
* near the top, and past the right edge whenever the panel is narrow.
|
||||
*
|
||||
* The host primitive is not ours to patch, so the fix is a body attribute
|
||||
* written while one of OUR submenu-bearing menus is open: layout.css flips
|
||||
* the submenu's growth direction per token. The attribute's lifetime IS the
|
||||
* scope — only our menus set it, and layout.css rules for
|
||||
* `div[role="menu"] div[role="menu"]` only apply while it exists, so
|
||||
* host-owned menus are never touched.
|
||||
*/
|
||||
|
||||
import { useEffect } from 'react'
|
||||
|
||||
/** body attribute carrying the flip tokens (whitespace-separated words). */
|
||||
export const SUBMENU_FLIP_ATTR = 'data-dsh-sidebar-submenu'
|
||||
|
||||
/**
|
||||
* Mirror of the primitive's submenu geometry (Menu.module.css): card
|
||||
* min-width 218 + card-to-submenu gap 10 + submenu min-width 163..165 +
|
||||
* viewport margin 12 ≈ 400px of right-hand room a right-growing submenu
|
||||
* needs. If the cursor is within that of the right edge, open left instead.
|
||||
* The estimate is conservative (the primitive's own clamp can only move the
|
||||
* card further left), so a wrong guess flips left unnecessarily — never into
|
||||
* a new overflow — and drifting from future host metrics degrades the same
|
||||
* way, back to today's behavior at worst.
|
||||
*/
|
||||
const SUBMENU_RIGHT_ROOM = 400
|
||||
|
||||
/** Below the vertical midpoint the parent row usually has more room above. */
|
||||
function isUpperHalf(y: number, vh: number): boolean {
|
||||
return y < vh / 2
|
||||
}
|
||||
|
||||
/**
|
||||
* The flip tokens for a menu opened at viewport coordinates (x, y):
|
||||
* `down` — open the submenu downward from the parent row (cursor in the
|
||||
* upper half; the sidebar's tab strip is always up there);
|
||||
* `left` — open the submenu leftward of the card (not enough right-hand
|
||||
* room). Empty string when the primitive's defaults already fit.
|
||||
*/
|
||||
export function submenuFlipTokens(x: number, y: number, vw: number, vh: number): string {
|
||||
const tokens: string[] = []
|
||||
if (isUpperHalf(y, vh)) tokens.push('down')
|
||||
if (x + SUBMENU_RIGHT_ROOM > vw) tokens.push('left')
|
||||
return tokens.join(' ')
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish the flip tokens on <body> while the given cursor-anchored menu
|
||||
* state is open; clear them on close and on unmount (a menu left open by an
|
||||
* unmounting panel must not leave stale geometry on the shared body).
|
||||
* `menu` only needs the viewport coordinates the Menu anchored to.
|
||||
*/
|
||||
export function useSubmenuFlip(menu: { x: number; y: number } | null): void {
|
||||
useEffect(() => {
|
||||
if (menu === null) return
|
||||
document.body.setAttribute(SUBMENU_FLIP_ATTR, submenuFlipTokens(menu.x, menu.y, window.innerWidth, window.innerHeight))
|
||||
return () => { document.body.removeAttribute(SUBMENU_FLIP_ATTR) }
|
||||
}, [menu])
|
||||
}
|
||||
@@ -1038,6 +1038,83 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Inline rename editor ─────────────────────────────────────────────────
|
||||
The row swaps its name span for a plain input (same row metrics: 34px
|
||||
height, 6px gap, xxs type); only tokens, no hardcoded colors. */
|
||||
.explorerRenaming {
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.explorerRenameInput {
|
||||
box-sizing: border-box;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
height: 24px;
|
||||
padding: 0 6px;
|
||||
border: 1px solid var(--dsw-alias-state-business-primary);
|
||||
border-radius: 6px;
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font: var(--dsw-font-xxs-12);
|
||||
}
|
||||
|
||||
.explorerRenameInput:focus {
|
||||
outline: none;
|
||||
border-color: var(--dsw-alias-state-business-primary);
|
||||
}
|
||||
|
||||
/* The dismissable mutation-failure strip above the root row (rename/delete
|
||||
refusals — raw server text, same policy as the fence notice). */
|
||||
.explorerActionError {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
margin-bottom: 4px;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid var(--dsw-alias-state-error-primary);
|
||||
border-radius: 8px;
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
}
|
||||
|
||||
.explorerActionErrorText {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
font: var(--dsw-font-xxs-12);
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
.explorerActionErrorClose {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.explorerActionErrorClose:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
/* The delete-confirmation modal's description paragraph (the git lens's
|
||||
.gitConfirmDesc sibling — same quiet single-question look). */
|
||||
.explorerConfirmDesc {
|
||||
margin: 0;
|
||||
font: var(--dsw-font-s-14);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* The hover-revealed @-reference button at the row's far right (and the
|
||||
transient "copied" label that replaces it after a copy). The only auto
|
||||
margin in the row absorbs the filename's spare width, so a short name
|
||||
@@ -2439,15 +2516,16 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
|
||||
}
|
||||
|
||||
/* The pushpin toggle on an "open with" submenu row: brand ink once the
|
||||
target is pinned to the menu's top level. */
|
||||
target is pinned to the menu's top level. Sized for the compact menu's
|
||||
26px rows (16px box, 12px glyph) — proportionate without overflowing. */
|
||||
.openWithPin {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 6px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 5px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -69,6 +69,7 @@ export function buildNewTabOptions(state: SidebarState, ctx: Context, scope: Ses
|
||||
id: d.id,
|
||||
label: typeof d.title === 'function' ? d.title() : d.title,
|
||||
disabled: !(d.available?.(ctx, scope, state) ?? true),
|
||||
icon: typeof d.icon === 'function' ? d.icon(16) : d.icon,
|
||||
// 14 matches the compact + menu's icon slot (compactList .itemIcon).
|
||||
icon: typeof d.icon === 'function' ? d.icon(14) : d.icon,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Open-tab reconciliation after a file-tree mutation (rename/delete).
|
||||
*
|
||||
* The tree owns the rows; the TABS live in the sidebar state (either split
|
||||
* tree, the bottom panel, or free windows). A rename must retarget every
|
||||
* tab whose `path` is the renamed file (the editor content survives and
|
||||
* later saves land on the new path); a delete must close every tab at or
|
||||
* under the removed path — files and anything inside a removed directory
|
||||
* (a stale tab's next save would fail against a missing path). Both ride
|
||||
* the SERVICE paths (`updateTab`/`closeTab`) rather than direct state
|
||||
* reducers so the registered lifecycle callbacks fire like any other tab
|
||||
* mutation.
|
||||
*/
|
||||
import type { Context } from '../context-types.ts'
|
||||
import { baseName } from './FileTree.tsx'
|
||||
import { allLeaves, type SidebarSnapshot, type SidebarStore, type SidebarTab } from './state.ts'
|
||||
import { isWithinWorkspace } from './paths.ts'
|
||||
|
||||
/** Every open tab that carries a file path (either split tree, the bottom
|
||||
* panel, and free windows — a floating tab is as open as a docked one). */
|
||||
function pathTabsOf(snapshot: SidebarSnapshot): SidebarTab[] {
|
||||
const state = snapshot.state
|
||||
if (state === undefined) return []
|
||||
const tabs: SidebarTab[] = []
|
||||
for (const leaf of allLeaves(state.splits)) tabs.push(...leaf.tabs)
|
||||
for (const leaf of allLeaves(state.bottomSplits)) tabs.push(...leaf.tabs)
|
||||
for (const float of state.floats) tabs.push(float.tab)
|
||||
return tabs.filter(tab => tab.path !== undefined)
|
||||
}
|
||||
|
||||
/** Retarget tabs after `oldPath` became `newPath` (title follows the new
|
||||
* base name, matching how openFile titles editor tabs). */
|
||||
export function retargetPathTabs(ctx: Context, store: SidebarStore, oldPath: string, newPath: string): void {
|
||||
const service = ctx.get('betterSidebar')
|
||||
if (service === undefined) return
|
||||
for (const tab of pathTabsOf(store.getSnapshot())) {
|
||||
if (tab.path === oldPath) service.updateTab(tab.id, { path: newPath, title: baseName(newPath) })
|
||||
}
|
||||
}
|
||||
|
||||
/** Close tabs at or under the removed `target` (a directory takes its whole
|
||||
* subtree of open files with it). */
|
||||
export function closePathTabs(ctx: Context, store: SidebarStore, target: string): void {
|
||||
const service = ctx.get('betterSidebar')
|
||||
if (service === undefined) return
|
||||
for (const tab of pathTabsOf(store.getSnapshot())) {
|
||||
const path = tab.path
|
||||
if (path !== undefined && (path === target || isWithinWorkspace(target, path))) service.closeTab(tab.id)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user