feat(desktop): only previewable change rows (md/html/image) get the open-with pill

A turn can touch many files; an open-with affordance on every row is noise.
Restrict the per-file 打开方式 pill to rendered-previewable types via a new
isPreviewableChangedFile() predicate. Source rows keep their inline diff toggle.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
程序员阿江(Relakkes) 2026-05-30 17:44:38 +08:00
parent 78baace37e
commit ac0cb4d0a0
4 changed files with 59 additions and 12 deletions

View File

@ -171,13 +171,25 @@ describe('CurrentTurnChangeCard open-with buttons', () => {
openPreviewSpy.mockResolvedValue(undefined)
})
it('renders one "open-with" button per file', () => {
it('renders an "open-with" button for each previewable file', () => {
renderCard(['/w/proj/README.md', '/w/proj/index.html'])
// aria-label is the i18n key itself (identity mock)
const buttons = screen.getAllByRole('button', { name: 'openWith.title' })
expect(buttons).toHaveLength(2)
})
it('does NOT render an "open-with" button for a source file (diff toggle stays)', () => {
renderCard(['/w/proj/src/main.ts'])
expect(screen.queryAllByRole('button', { name: 'openWith.title' })).toHaveLength(0)
// source files keep their inline diff toggle — only the open-with pill is dropped
expect(screen.getByRole('button', { name: /turnChangesShowDiffAria/ })).toBeInTheDocument()
})
it('mixed turn: only previewable rows (md/html) get the open-with button, not .ts', () => {
renderCard(['/w/proj/README.md', '/w/proj/src/main.ts', '/w/proj/index.html'])
expect(screen.getAllByRole('button', { name: 'openWith.title' })).toHaveLength(2)
})
it('clicking README.md open-with opens menu with workspace preview item', async () => {
renderCard(['/w/proj/README.md'])
const [openWithBtn] = screen.getAllByRole('button', { name: 'openWith.title' })

View File

@ -5,7 +5,7 @@ import { sessionsApi, type SessionTurnCheckpoint } from '../../api/sessions'
import { useTranslation, type TranslationKey } from '../../i18n'
import { WorkspaceDiffSurface } from '../workspace/WorkspaceCodeSurface'
import { OpenWithMenu } from '../common/OpenWithMenu'
import { buildOpenWithItems, describeFileType, type OpenWithItem } from '../../lib/openWithItems'
import { buildOpenWithItems, describeFileType, isPreviewableChangedFile, type OpenWithItem } from '../../lib/openWithItems'
import { openWithContextForWorkspaceFile } from '../../lib/openWithContextForHref'
import { getServerBaseUrl } from '../../lib/desktopRuntime'
import { useOpenTargetStore } from '../../stores/openTargetStore'
@ -176,6 +176,7 @@ export function CurrentTurnChangeCard({
const diffState = diffByPath[fileEntry.apiPath]
const fileName = fileEntry.displayPath.split('/').pop() || fileEntry.displayPath
const typeInfo = describeFileType(fileEntry.displayPath)
const previewable = isPreviewableChangedFile(fileEntry.displayPath)
return (
<div key={fileEntry.apiPath}>
<div className="flex items-center gap-2">
@ -196,15 +197,17 @@ export function CurrentTurnChangeCard({
</span>
<span className="material-symbols-outlined shrink-0 text-[18px] text-[var(--color-text-tertiary)]">{isExpanded ? 'keyboard_arrow_down' : 'chevron_right'}</span>
</button>
<button
type="button"
aria-label={t('openWith.title')}
onClick={(event) => handleOpenWith(event, fileEntry)}
className="mr-2 inline-flex h-8 shrink-0 items-center gap-1 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] px-2.5 text-xs font-medium text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35"
>
{t('openWith.title')}
<ChevronDown size={14} strokeWidth={1.9} />
</button>
{previewable && (
<button
type="button"
aria-label={t('openWith.title')}
onClick={(event) => handleOpenWith(event, fileEntry)}
className="mr-2 inline-flex h-8 shrink-0 items-center gap-1 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] px-2.5 text-xs font-medium text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35"
>
{t('openWith.title')}
<ChevronDown size={14} strokeWidth={1.9} />
</button>
)}
</div>
{isExpanded && (

View File

@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import { buildOpenWithItems, describeFileType, type OpenWithContext, type OpenWithDeps } from './openWithItems'
import { buildOpenWithItems, describeFileType, isPreviewableChangedFile, type OpenWithContext, type OpenWithDeps } from './openWithItems'
import type { OpenTarget } from '../stores/openTargetStore'
// ──────────────────────────────────────────────────────────────────────────────
@ -47,6 +47,26 @@ describe('describeFileType', () => {
})
})
// ──────────────────────────────────────────────────────────────────────────────
// isPreviewableChangedFile tests — only md/html/image get the open-with affordance
// ──────────────────────────────────────────────────────────────────────────────
describe('isPreviewableChangedFile', () => {
it.each([
'a.md', 'a.markdown', 'x.html', 'x.htm', 'X.HTML',
'y.png', 'y.JPG', 'z.jpeg', 'g.gif', 'w.webp', 'v.svg',
'docs/sub/readme.md',
])('previewable: %s → true', (p) => {
expect(isPreviewableChangedFile(p)).toBe(true)
})
it.each([
'main.ts', 'main.tsx', 'data.json', 'style.css', 'notes.txt',
'lib.rs', 'Makefile', 'archive.zip', 'no-ext', 'a.mdx',
])('non-previewable: %s → false', (p) => {
expect(isPreviewableChangedFile(p)).toBe(false)
})
})
function makeT() {
return (key: string, vars?: Record<string, string>) =>
vars?.target != null ? `${key}:${vars.target}` : key

View File

@ -19,6 +19,18 @@ export function describeFileType(path: string): FileTypeInfo {
return { icon: 'insert_drive_file', categoryKey: 'openWith.fileType.file', ext }
}
const PREVIEWABLE_CHANGED_FILE_RE = /\.(md|markdown|html?|png|jpe?g|gif|webp|svg)$/i
/**
* True only for changed-file types with a meaningful *rendered* preview
* (markdown / html / image). Source files (.ts/.json/.css ) return false.
* Used to decide which change-card rows get the "open with" affordance
* we don't want an open-with pill on every file when a turn touches many.
*/
export function isPreviewableChangedFile(path: string): boolean {
return PREVIEWABLE_CHANGED_FILE_RE.test(path)
}
// ─── Open-with items ──────────────────────────────────────────────────────────
export type OpenWithIcon = 'in-app-browser' | 'system' | 'ide' | 'file-manager' | 'preview'