feat: explorer copy-path & @-reference actions; icons on tabs and + menu
- Tabs, the + menu, and empty-pane cards carry per-type icons (folder / branch / terminal / code file); a terminal glyph joins the icon set - Hovering an explorer row reveals an @文件 button at the far right that appends @<relative path> to the composer draft (session-scoped input service, space-separated append) - Right-clicking a row opens a context menu: copy relative / absolute path (writeClipboard + transient in-row "copied" label) - Rows become div role=button (Enter/Space) to host the inner button; relativeTo helper unit-tested
This commit is contained in:
@@ -11,6 +11,8 @@ DeepSeek Harness Web GUI 的 **VSCode 风格右侧侧边栏**插件:文件资
|
||||
- 懒加载目录树,根目录 = 当前会话工作目录(cwd)
|
||||
- 目录优先排序、隐藏文件(点开头)变暗显示、每层 1000 项上限
|
||||
- 点击文件在侧边栏打开编辑器;刷新按钮重载
|
||||
- **右键行**弹出菜单:复制相对地址 / 复制绝对地址(复制成功后行内短暂显示"已复制")
|
||||
- **悬浮行尾的 `@文件` 按钮**:点击把 `@相对路径` 追加到输入框草稿(如 `@src/main.ts`)
|
||||
|
||||
### 文件编辑与预览(Editor)
|
||||
- **CodeMirror 6** 编辑器:**自动换行** + 按扩展名**语法高亮**(js/ts/jsx/tsx、json、python、html、css、xml、yaml、sql、java、c/c++、rust、go、php、shell、toml、nginx、dockerfile、properties…),**明暗主题自适应**(跟随应用色系,切换时文档/撤销/滚动不丢)
|
||||
@@ -33,6 +35,7 @@ DeepSeek Harness Web GUI 的 **VSCode 风格右侧侧边栏**插件:文件资
|
||||
|
||||
### Tab 栏
|
||||
- Tab 最大宽度 160px、横向滚动;**`+` 按钮紧贴最右 Tab**(溢出时吸附右缘)
|
||||
- **Tab 与 `+` 菜单均带类型图标**(Explorer=文件夹、Git=分支、终端=终端、编辑器=代码文件)
|
||||
- `+` 菜单新建 Tab(Explorer / Git / 终端,终端受 3 个上限约束);**中键点击关闭 Tab**
|
||||
- 空分栏不再显示空提示,而是**可打开类型卡片**(点击即开)
|
||||
|
||||
|
||||
+127
-10
@@ -3,13 +3,20 @@
|
||||
* working directory. Levels load on expansion (one API call per directory),
|
||||
* directories sort first, hidden entries render dimmed, and the expansion
|
||||
* set lives in the per-session state. Clicking a file opens an editor tab.
|
||||
*
|
||||
* Row actions: hovering a row reveals an @-reference button on the far
|
||||
* right (appends `@<relative path>` to the composer draft), and right-click
|
||||
* opens a context menu to copy the relative or absolute path (with a brief
|
||||
* "copied" label replacing the button after a successful write).
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState, type MouseEvent, type ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
IconCodeOutline16, IconFolderClose16, IconFolderOpen16, IconRefreshOutline16,
|
||||
IconCodeOutline16, IconCopyOutline16, IconFolderClose16, IconFolderOpen16,
|
||||
IconRefreshOutline16, Menu, writeClipboard,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { api, type FsEntry } from './api.ts'
|
||||
import { relativeTo } from './paths.ts'
|
||||
import { t } from './locales.ts'
|
||||
import css from './sidebar.module.css'
|
||||
|
||||
@@ -25,17 +32,26 @@ function baseName(path: string): string {
|
||||
return at === -1 ? trimmed : trimmed.slice(at + 1)
|
||||
}
|
||||
|
||||
/** How long the row's "copied" label stays after a successful write. */
|
||||
const COPIED_MS = 1200
|
||||
|
||||
export function ExplorerView(props: {
|
||||
sessionId: string
|
||||
cwd: string | undefined
|
||||
expanded: string[]
|
||||
onToggle: (path: string) => void
|
||||
onOpenFile: (path: string) => void
|
||||
/** Insert `@<relative path>` into the composer draft. */
|
||||
onReferenceFile: (path: string) => void
|
||||
}) {
|
||||
const { sessionId, cwd, expanded, onToggle, onOpenFile } = props
|
||||
const { sessionId, cwd, expanded, onToggle, onOpenFile, onReferenceFile } = props
|
||||
const [data, setData] = useState<Record<string, LevelData>>({})
|
||||
const dataRef = useRef(data)
|
||||
const [refreshTick, setRefreshTick] = useState(0)
|
||||
/** 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 plus the cursor position. */
|
||||
const [rowMenu, setRowMenu] = useState<{ path: string; x: number; y: number } | null>(null)
|
||||
|
||||
const storeLevel = useCallback((path: string, level: LevelData) => {
|
||||
dataRef.current = { ...dataRef.current, [path]: level }
|
||||
@@ -61,6 +77,44 @@ export function ExplorerView(props: {
|
||||
for (const dir of expanded) loadDir(dir)
|
||||
}, [cwd, expanded, refreshTick, loadDir])
|
||||
|
||||
/** Copy `text`; on success flip the row's copied label for a moment. */
|
||||
const copyPath = useCallback((text: string, path: string): void => {
|
||||
void writeClipboard(text).then((ok) => {
|
||||
if (!ok) return
|
||||
setCopiedPath(path)
|
||||
window.setTimeout(() => {
|
||||
setCopiedPath(current => current === path ? null : current)
|
||||
}, COPIED_MS)
|
||||
})
|
||||
}, [])
|
||||
|
||||
/** The row's trailing actions: the @-reference button, or the copied label. */
|
||||
const rowActions = (entry: FsEntry): ReactNode => {
|
||||
if (copiedPath === entry.path) {
|
||||
return <span className={css.explorerCopied}>{t('copied')}</span>
|
||||
}
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={css.explorerRef}
|
||||
aria-label={t('referenceFile')}
|
||||
title={t('referenceFile')}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onReferenceFile(entry.path)
|
||||
}}
|
||||
>
|
||||
{t('referenceFile')}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
const openRowMenu = (event: MouseEvent, path: string): void => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
setRowMenu({ path, x: event.clientX, y: event.clientY })
|
||||
}
|
||||
|
||||
const root = cwd
|
||||
|
||||
const renderLevel = (dir: string, depth: number): ReactNode => {
|
||||
@@ -81,31 +135,49 @@ export function ExplorerView(props: {
|
||||
const isOpen = expanded.includes(entry.path)
|
||||
return (
|
||||
<div key={entry.path}>
|
||||
<button
|
||||
type="button"
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={clsx(css.explorerRow, css.explorerDir, entry.hidden && css.explorerHidden)}
|
||||
style={{ paddingLeft: depth * 22 + 6 }}
|
||||
onClick={() => { onToggle(entry.path) }}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
onToggle(entry.path)
|
||||
}
|
||||
}}
|
||||
onContextMenu={(event) => { openRowMenu(event, entry.path) }}
|
||||
>
|
||||
{isOpen ? <IconFolderOpen16 size={14} /> : <IconFolderClose16 size={14} />}
|
||||
<span className={css.explorerName}>{entry.name}</span>
|
||||
</button>
|
||||
{rowActions(entry)}
|
||||
</div>
|
||||
{isOpen && renderLevel(entry.path, depth + 1)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<button
|
||||
<div
|
||||
key={entry.path}
|
||||
type="button"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={clsx(css.explorerRow, entry.hidden && css.explorerHidden)}
|
||||
style={{ paddingLeft: depth * 22 + 6 }}
|
||||
title={entry.path}
|
||||
onClick={() => { onOpenFile(entry.path) }}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
onOpenFile(entry.path)
|
||||
}
|
||||
}}
|
||||
onContextMenu={(event) => { openRowMenu(event, entry.path) }}
|
||||
>
|
||||
<IconCodeOutline16 size={14} />
|
||||
<span className={css.explorerName}>{entry.name}</span>
|
||||
</button>
|
||||
{rowActions(entry)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -133,14 +205,59 @@ export function ExplorerView(props: {
|
||||
<div className={css.explorerEmpty}>{t('noSession')}</div>
|
||||
) : (
|
||||
<>
|
||||
<div className={css.explorerRow} style={{ paddingLeft: 6 }}>
|
||||
<div
|
||||
className={css.explorerRow}
|
||||
style={{ paddingLeft: 6 }}
|
||||
onContextMenu={(event) => { openRowMenu(event, root) }}
|
||||
>
|
||||
<IconFolderOpen16 size={14} />
|
||||
<span className={css.explorerName}>{baseName(root)}</span>
|
||||
{copiedPath === root
|
||||
? <span className={css.explorerCopied}>{t('copied')}</span>
|
||||
: (
|
||||
<button
|
||||
type="button"
|
||||
className={css.explorerRef}
|
||||
aria-label={t('referenceFile')}
|
||||
title={t('referenceFile')}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onReferenceFile(root)
|
||||
}}
|
||||
>
|
||||
{t('referenceFile')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{data[root] !== undefined && renderLevel(root, 1)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{/*
|
||||
The one shared context menu, positioned at the right-click cursor
|
||||
(portal so the explorer's overflow clip cannot crop it).
|
||||
*/}
|
||||
<Menu
|
||||
open={rowMenu !== null}
|
||||
onClose={() => { setRowMenu(null) }}
|
||||
items={[
|
||||
{ id: 'relative', label: t('copyRelative'), icon: <IconCopyOutline16 size={14} /> },
|
||||
{ id: 'absolute', label: t('copyAbsolute'), icon: <IconCopyOutline16 size={14} /> },
|
||||
]}
|
||||
onSelect={(id) => {
|
||||
const target = rowMenu
|
||||
if (target === null) return
|
||||
setRowMenu(null)
|
||||
copyPath(
|
||||
id === 'relative' ? relativeTo(cwd ?? '', target.path) : target.path,
|
||||
target.path,
|
||||
)
|
||||
}}
|
||||
portal
|
||||
align="start"
|
||||
getAnchorRect={() => (rowMenu === null ? null : new DOMRect(rowMenu.x, rowMenu.y, 0, 0))}
|
||||
anchor={<span />}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+28
-3
@@ -22,6 +22,8 @@ import {
|
||||
import { Workbench, type WorkbenchActions } from './split-pane.tsx'
|
||||
import type { NewTabOption } from './TabBar.tsx'
|
||||
import type { TabDragPayload } from './TabBar.tsx'
|
||||
import { tabTypeIcon } from './icons.tsx'
|
||||
import { relativeTo } from './paths.ts'
|
||||
import { ExplorerView } from './ExplorerView.tsx'
|
||||
import { EditorView } from './EditorView.tsx'
|
||||
import { TerminalView } from './TerminalView.tsx'
|
||||
@@ -38,9 +40,10 @@ function TabContent(props: {
|
||||
cwd: string | undefined
|
||||
expanded: string[]
|
||||
onToggleDir: (path: string) => void
|
||||
onReferenceFile: (path: string) => void
|
||||
ctx: Context
|
||||
}) {
|
||||
const { tab, sessionId, cwd, expanded, onToggleDir, ctx } = props
|
||||
const { tab, sessionId, cwd, expanded, onToggleDir, onReferenceFile, ctx } = props
|
||||
const scope = { sessionId, cwd }
|
||||
switch (tab.type) {
|
||||
case 'explorer':
|
||||
@@ -51,6 +54,7 @@ function TabContent(props: {
|
||||
expanded={expanded}
|
||||
onToggle={onToggleDir}
|
||||
onOpenFile={(path) => { openSidebarFile(ctx, sessionId, path) }}
|
||||
onReferenceFile={onReferenceFile}
|
||||
/>
|
||||
)
|
||||
case 'git':
|
||||
@@ -68,12 +72,13 @@ function buildNewTabOptions(state: SidebarState): NewTabOption[] {
|
||||
.flatMap(leaf => leaf.tabs)
|
||||
.filter(tab => tab.type === 'terminal').length
|
||||
return [
|
||||
{ id: 'explorer', label: t('openExplorer') },
|
||||
{ id: 'git', label: t('openGit') },
|
||||
{ id: 'explorer', label: t('openExplorer'), icon: tabTypeIcon('explorer', 16) },
|
||||
{ id: 'git', label: t('openGit'), icon: tabTypeIcon('git', 16) },
|
||||
{
|
||||
id: 'terminal',
|
||||
label: terminalCount >= TERMINAL_LIMIT ? `${t('newTerminal')} (${t('terminalLimit')})` : t('newTerminal'),
|
||||
disabled: terminalCount >= TERMINAL_LIMIT,
|
||||
icon: tabTypeIcon('terminal', 16),
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -214,6 +219,25 @@ export function Sidebar(props: { ctx: Context }) {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* The explorer's @-reference button: append `@<relative path>` to the
|
||||
* session's composer draft (space-separated). Resolves the session-scope
|
||||
* ctx and the conversation input service at click time; a missing service
|
||||
* or scope degrades to a logged no-op, never a crash.
|
||||
*/
|
||||
const referenceInChat = useCallback((path: string): void => {
|
||||
try {
|
||||
const actx = ctx.sessions.scope(sessionId)
|
||||
if (actx === undefined) return
|
||||
const input = ctx.conversation.input.for(actx)
|
||||
const mention = `@${relativeTo(cwd ?? '', path)}`
|
||||
const draft = input.state.getSnapshot().draft
|
||||
input.setDraft(draft.trim() === '' ? mention : `${draft} ${mention}`)
|
||||
} catch (error) {
|
||||
console.warn('[dsh-better-sidebar] reference insert failed:', error)
|
||||
}
|
||||
}, [ctx, sessionId, cwd])
|
||||
|
||||
const renderTab = (tab: SidebarTab) => (
|
||||
<TabContent
|
||||
tab={tab}
|
||||
@@ -221,6 +245,7 @@ export function Sidebar(props: { ctx: Context }) {
|
||||
cwd={cwd}
|
||||
expanded={state.expanded}
|
||||
onToggleDir={(path) => { sidebarStore.reduce(s => toggleExpanded(s, path)) }}
|
||||
onReferenceFile={referenceInChat}
|
||||
ctx={ctx}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
IconCloseFill14, IconPlusOutline16, Menu,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { SidebarTab } from './state.ts'
|
||||
import { tabTypeIcon } from './icons.tsx'
|
||||
import { t } from './locales.ts'
|
||||
import css from './sidebar.module.css'
|
||||
|
||||
@@ -19,6 +20,8 @@ export interface NewTabOption {
|
||||
id: string
|
||||
label: string
|
||||
disabled?: boolean
|
||||
/** Leading icon (Menu row). */
|
||||
icon?: ReactNode
|
||||
}
|
||||
|
||||
/** Drag payload for tab moves (HTML5 DnD dataTransfer). */
|
||||
@@ -109,6 +112,7 @@ export function TabBar(props: {
|
||||
}
|
||||
}}
|
||||
>
|
||||
{tabTypeIcon(tab.type, 14)}
|
||||
<span className={css.tabTitle}>{tab.title}</span>
|
||||
<button
|
||||
type="button"
|
||||
@@ -135,6 +139,7 @@ export function TabBar(props: {
|
||||
id: option.id,
|
||||
label: option.label,
|
||||
...(option.disabled === true ? { disabled: true } : {}),
|
||||
...(option.icon !== undefined ? { icon: option.icon } : {}),
|
||||
}))}
|
||||
onSelect={(id) => {
|
||||
onNewTab(id)
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Icons the sidebar needs beyond the primitives set: a terminal glyph (the
|
||||
* icon library has none) and the per-tab-type icon mapping shared by the
|
||||
* tab strip, the + menu, and the empty-pane cards.
|
||||
*/
|
||||
import type { ReactNode } from 'react'
|
||||
import {
|
||||
IconBranchOutline16, IconCodeOutline16, IconFolderOpen16,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { IconProps } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { TabType } from './state.ts'
|
||||
|
||||
/**
|
||||
* Terminal glyph in the app's outline style (1.5px stroke, currentColor):
|
||||
* a rounded frame with a prompt chevron and underscore cursor.
|
||||
*/
|
||||
export const IconTerminalOutline16 = ({ size = 16, className }: IconProps) => (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="1.5" y="2.5" width="13" height="11" rx="2" stroke="currentColor" strokeWidth="1.5" />
|
||||
<path d="M4.5 6.25 6.75 8 4.5 9.75" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M8.5 10.4h3" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** The one icon per tab type (shared by tabs, the + menu, and pane cards). */
|
||||
export function tabTypeIcon(type: TabType, size = 16): ReactNode {
|
||||
switch (type) {
|
||||
case 'explorer': return <IconFolderOpen16 size={size} />
|
||||
case 'git': return <IconBranchOutline16 size={size} />
|
||||
case 'terminal': return <IconTerminalOutline16 size={size} />
|
||||
case 'editor': return <IconCodeOutline16 size={size} />
|
||||
}
|
||||
}
|
||||
@@ -66,6 +66,9 @@ const zh = {
|
||||
newFile: '新文件',
|
||||
openEditor: '打开编辑器',
|
||||
gitDetail: '查看变更详情',
|
||||
referenceFile: '@文件',
|
||||
copyRelative: '复制相对地址',
|
||||
copyAbsolute: '复制绝对地址',
|
||||
}
|
||||
|
||||
const en: Record<keyof typeof zh, string> = {
|
||||
@@ -129,6 +132,9 @@ const en: Record<keyof typeof zh, string> = {
|
||||
newFile: 'New file',
|
||||
openEditor: 'Open editor',
|
||||
gitDetail: 'View change details',
|
||||
referenceFile: '@file',
|
||||
copyRelative: 'Copy relative path',
|
||||
copyAbsolute: 'Copy absolute path',
|
||||
}
|
||||
|
||||
/** Translate a copy key in the browser's language (zh → zh, else en). */
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Path projection helpers shared by the explorer rows: a path relative to
|
||||
* the session cwd (for the @-reference button and "copy relative path").
|
||||
* The fs-tree joins with '/' even on Windows, so both separators normalize
|
||||
* to '/' before comparison.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The path relative to the session's working directory.
|
||||
* @param cwd - the explorer root (absolute).
|
||||
* @param path - an absolute entry path from the fs-tree.
|
||||
* @returns the relative path with '/' separators ('.' for the cwd itself),
|
||||
* or `path` unchanged when it lies outside the cwd.
|
||||
*/
|
||||
export function relativeTo(cwd: string, path: string): string {
|
||||
const base = cwd.replace(/[\\/]+$/, '')
|
||||
const norm = (value: string): string => value.replace(/\\/g, '/')
|
||||
const nBase = norm(base)
|
||||
const nPath = norm(path)
|
||||
if (nPath === nBase) return '.'
|
||||
if (nPath.startsWith(`${nBase}/`)) return nPath.slice(nBase.length + 1)
|
||||
return path
|
||||
}
|
||||
@@ -502,6 +502,39 @@
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* The hover-revealed @-reference button at the row's far right (and the
|
||||
transient "copied" label that replaces it after a copy). Hidden rows
|
||||
keep the name at full width; hovering or focusing the row reveals it. */
|
||||
.explorerRef {
|
||||
flex: none;
|
||||
display: none;
|
||||
align-items: center;
|
||||
height: 20px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid var(--dsw-alias-border-l1);
|
||||
border-radius: 999px;
|
||||
background: var(--dsw-alias-bg-layer-2);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font: var(--dsw-font-xxxs-strong-11);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.explorerRef:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.explorerRow:hover .explorerRef,
|
||||
.explorerRow:focus-within .explorerRef {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.explorerCopied {
|
||||
flex: none;
|
||||
font: var(--dsw-font-xxxs-11);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.explorerError {
|
||||
font: var(--dsw-font-xxs-12);
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
@@ -998,6 +1031,7 @@
|
||||
.tabBarPlus:focus-visible,
|
||||
.paneCard:focus-visible,
|
||||
.explorerRow:focus-visible,
|
||||
.explorerRef:focus-visible,
|
||||
.gitRowMain:focus-visible,
|
||||
.gitLink:focus-visible,
|
||||
.gitCommitButton:focus-visible,
|
||||
|
||||
@@ -12,12 +12,10 @@
|
||||
import { Fragment, useRef, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
IconBranchOutline16, IconCodeOutline16, IconFolderOpen16,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { SidebarState, SidebarTab, SplitNode } from './state.ts'
|
||||
import type { DropZone } from './state.ts'
|
||||
import { TabBar, type NewTabOption, parseDrag, type TabDragPayload } from './TabBar.tsx'
|
||||
import { tabTypeIcon } from './icons.tsx'
|
||||
import css from './sidebar.module.css'
|
||||
|
||||
/** Actions the workbench needs (bound to the store by the sidebar shell). */
|
||||
@@ -82,15 +80,6 @@ function zoneAt(event: React.DragEvent, pane: HTMLElement): DropZone {
|
||||
}
|
||||
|
||||
/** The icon of one openable type card (mirror of the + menu options). */
|
||||
function optionIcon(optionId: string): ReactNode {
|
||||
switch (optionId) {
|
||||
case 'explorer': return <IconFolderOpen16 size={16} />
|
||||
case 'git': return <IconBranchOutline16 size={16} />
|
||||
case 'terminal': return <IconCodeOutline16 size={16} />
|
||||
default: return <IconCodeOutline16 size={16} />
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An empty pane's welcome cards: the openable types as cards, clicked to
|
||||
* open (instead of a bare "this pane is empty" message).
|
||||
@@ -111,7 +100,7 @@ function PaneEmptyCards(props: {
|
||||
title={option.label}
|
||||
onClick={() => { onNewTab(option.id) }}
|
||||
>
|
||||
{optionIcon(option.id)}
|
||||
{option.icon ?? tabTypeIcon(option.id as SidebarTab['type'], 16)}
|
||||
<span>{option.label}</span>
|
||||
</button>
|
||||
))}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
* - httpServer: @deepseek-ai/dsh-host-webserver
|
||||
* - sessions: host side @deepseek-ai/dsh-session (SessionStore), client
|
||||
* side the runtime ISessions list feed
|
||||
* - conversation: client side ui-conversation's IConversation (composer draft)
|
||||
* - loader: @cordisjs/plugin-loader (entry options)
|
||||
* - slots: the client runtime SlotsService
|
||||
* - effect: the DSH-vendored cordis lifecycle helper
|
||||
@@ -97,12 +98,36 @@ export interface SidebarSessionsService {
|
||||
getSnapshot(): SidebarSessionList
|
||||
subscribe(fn: () => void): () => void
|
||||
}
|
||||
/**
|
||||
* Resolve an Agent-scoped context view for one session (mirror of the
|
||||
* runtime ISessions.scope) — the ticket `ctx.conversation.input.for`
|
||||
* requires to reach that session's composer.
|
||||
*/
|
||||
scope(id: string): Context | undefined
|
||||
}
|
||||
|
||||
/** The composer draft face the sidebar reaches through `ctx.conversation.input`. */
|
||||
export interface SidebarSessionInput {
|
||||
/** The live input store (draft read for append). */
|
||||
state: {
|
||||
getSnapshot(): { draft: string }
|
||||
}
|
||||
/** Replace the draft text (the input machine's single public write path). */
|
||||
setDraft(text: string): void
|
||||
}
|
||||
|
||||
/** The conversation service face (mirror of ui-conversation's IConversation). */
|
||||
export interface SidebarConversation {
|
||||
input: {
|
||||
for(actx: Context): SidebarSessionInput
|
||||
}
|
||||
}
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
httpServer: SidebarHttpServer
|
||||
sessions: SidebarSessionStore & SidebarSessionsService
|
||||
conversation: SidebarConversation
|
||||
loader: SidebarLoader
|
||||
slots: SidebarSlotsService
|
||||
/**
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
type SidebarState, type SplitNode,
|
||||
} from '../src/client/state.ts'
|
||||
import { extOf, languageKeyForExt } from '../src/client/lang.ts'
|
||||
import { relativeTo } from '../src/client/paths.ts'
|
||||
import { producedForClosing, resolveSidebarPath, selectProducedFiles } from '../src/client/produced-files.ts'
|
||||
import { defaultShell, ensureSpawnHelper } from '../src/pty-manager.ts'
|
||||
|
||||
@@ -318,6 +319,25 @@ describe('persisted state sanitization', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('path helpers', () => {
|
||||
it('derives relative paths under the cwd (and "." for the cwd itself)', () => {
|
||||
expect(relativeTo('/Users/me/code', '/Users/me/code/src/main.ts')).toBe('src/main.ts')
|
||||
expect(relativeTo('/Users/me/code', '/Users/me/code')).toBe('.')
|
||||
expect(relativeTo('/Users/me/code/', '/Users/me/code/src/a/b.ts')).toBe('src/a/b.ts')
|
||||
})
|
||||
|
||||
it('falls back to the path unchanged when it lies outside the cwd', () => {
|
||||
expect(relativeTo('/Users/me/code', '/Users/other/x.ts')).toBe('/Users/other/x.ts')
|
||||
expect(relativeTo('/Users/me/code', '/Users/me/codex/y.ts')).toBe('/Users/me/codex/y.ts')
|
||||
})
|
||||
|
||||
it('handles windows roots and mixed separators', () => {
|
||||
expect(relativeTo('C:\\Users\\me', 'C:\\Users\\me\\src\\a.ts')).toBe('src/a.ts')
|
||||
expect(relativeTo('C:\\Users\\me', 'C:/Users/me/src/a.ts')).toBe('src/a.ts')
|
||||
expect(relativeTo('C:\\Users\\me\\', 'C:\\Users\\me')).toBe('.')
|
||||
})
|
||||
})
|
||||
|
||||
describe('editor language mapping', () => {
|
||||
it('derives extensions from paths', () => {
|
||||
expect(extOf('/a/b/main.tsx')).toBe('tsx')
|
||||
|
||||
Reference in New Issue
Block a user