Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ describe('Markdown target rendering contract', () => {
// 粘贴内容,会把整个节点连同路径文字丢掉,只在原位留下一个断行——用户看到的
// 就是"文件名凭空消失"。`<code>` 既是语义正解(这个 chip 本来就是行内代码
// 升级来的),粘出去还会落成对方的行内代码。
const chipBody = markdownRenderer.match(/function FileTargetChip\([\s\S]*?\n}\n/)?.[0];
const chipBody = markdownRenderer.match(/function FileTargetChip\([\s\S]*?\r?\n}\r?\n/)?.[0];
expect(chipBody).toBeDefined();
expect(chipBody).toContain('<code');
expect(chipBody).not.toContain('<button');
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';

import { describe, expect, it } from 'vitest';

import { getEffectiveAppShortcuts } from '../../shared/appShortcuts';

const rendererRoot = resolve(__dirname, '..');
const read = (...parts: string[]) =>
readFileSync(resolve(rendererRoot, ...parts), 'utf8').replace(/\r\n/g, '\n');

const projectNodeSource = read('features', 'cc-agent', 'sidebar', 'sections', 'ProjectNode.tsx');
const sessionItemSource = read('features', 'cc-agent', 'sidebar', 'SessionItem.tsx');
const sessionCardSource = read('features', 'cc-agent', 'sidebar', 'SessionCard.tsx');
const sessionHeaderSource = read('features', 'cc-agent', 'SessionContentHeader.tsx');
const menuItemSource = read('features', 'cc-agent', 'sidebar', 'ShowInExplorerMenuItem.tsx');

describe('sidebar show-in-explorer shortcut contract', () => {
it('uses Ctrl+Shift+S on Windows/Linux and Command+Shift+S on macOS', () => {
expect(getEffectiveAppShortcuts({}, 'win32').get('show-in-explorer')).toEqual([
{ code: 'KeyS', meta: false, ctrl: true, alt: false, shift: true },
]);
expect(getEffectiveAppShortcuts({}, 'linux').get('show-in-explorer')).toEqual([
{ code: 'KeyS', meta: false, ctrl: true, alt: false, shift: true },
]);
expect(getEffectiveAppShortcuts({}, 'darwin').get('show-in-explorer')).toEqual([
{ code: 'KeyS', meta: true, ctrl: false, alt: false, shift: true },
]);
});

it('exposes the action only for local project paths', () => {
expect(projectNodeSource).toContain(
'const canShowInExplorer = !isRemote && !isDeviceLink && Boolean(project.workingDir)',
);
expect(sessionItemSource).toContain(
"session.workspaceKind === 'project' &&\n Boolean(session.workingDir) &&\n !session.remoteHostId &&\n !session.deviceLinkDeviceId",
);
expect(sessionCardSource).toContain(
"session.workspaceKind === 'project' &&\n Boolean(session.workingDir) &&\n !session.remoteHostId &&\n !session.deviceLinkDeviceId",
);
expect(sessionHeaderSource).toContain(
"session.workspaceKind === 'project' &&\n Boolean(session.workingDir) &&\n !session.remoteHostId &&\n !session.deviceLinkDeviceId",
);
});

it('binds the shortcut only while the shared menu item is mounted', () => {
expect(menuItemSource).toMatch(/useAppShortcut\(\s*'show-in-explorer'/);
expect(menuItemSource).toContain("useAppShortcutDisplay('show-in-explorer')");
for (const source of [
projectNodeSource,
sessionItemSource,
sessionCardSource,
sessionHeaderSource,
]) {
expect(source).toContain('ShowInExplorerMenuItem');
expect(source).not.toContain("useAppShortcutDisplay('show-in-explorer')");
}
});

it('renders the shortcut hint beside the existing explorer action', () => {
expect(projectNodeSource).toContain('ccAgent.sidebar.projectAction.openInExplorer');
expect(sessionItemSource).toContain('ccAgent.sidebar.sessionMenu.openInExplorer');
expect(sessionCardSource).toContain('ccAgent.sidebar.sessionMenu.openInExplorer');
expect(sessionHeaderSource).toContain('ccAgent.sidebar.sessionMenu.openInExplorer');
expect(menuItemSource).toContain('shortcut &&');
expect(menuItemSource).toContain('text-[var(--cmd-palette-item-meta)]');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ import { SessionShareExportDialog } from './sidebar/SessionShareExportDialog';
import { useRemoteProjectSessions } from '@/features/device-link/remoteProjectsStore';
import { isRemoteSessionWriteBlocked } from './lib/remoteSessionWriteGuard';
import { Tip } from '@/components/ui/tooltip';
import { ShowInExplorerMenuItem } from './sidebar/ShowInExplorerMenuItem';

const log = createLogger('SessionContentHeader');

Expand Down Expand Up @@ -146,6 +147,11 @@ export function SessionContentHeader({
!isEmpty && !session.remoteHostId && !session.deviceLinkDeviceId && !isArchived;
const canExportShare =
!isEmpty && !session.remoteHostId && !session.orcaRole && !session.deviceLinkDeviceId;
const canShowInExplorer =
session.workspaceKind === 'project' &&
Boolean(session.workingDir) &&
!session.remoteHostId &&
!session.deviceLinkDeviceId;
const projectOptions = useProjectPickerOptions();
// heartbeat schedule 绑定标识,与 SessionItem 同源数据;删除/过期后自动消失。
const boundSchedules = useSessionBoundSchedules(session.id);
Expand Down Expand Up @@ -269,6 +275,29 @@ export function SessionContentHeader({
void window.electronAPI.maker.openSessionInNewWindow(session.id);
}, [remoteWritesBlocked, session.id, showRemoteWriteBlockedToast]);

const [sessionMenuOpen, setSessionMenuOpen] = useState(false);
const handleShowInExplorer = useCallback(async () => {
if (!canShowInExplorer || !session.workingDir) return;
setSessionMenuOpen(false);
try {
const result = await window.electronAPI.openPath(session.workingDir);
if (!result.success) {
toast.error(result.error || t('ccAgent.common.openFolderFailed'));
}
} catch (err) {
log.error('[show in explorer]', err);
toast.error(t('ccAgent.common.openFolderFailed'));
}
}, [canShowInExplorer, session.workingDir, t]);

const showInExplorerMenuItem = canShowInExplorer ? (
<ShowInExplorerMenuItem
enabled={sessionMenuOpen}
label={t('ccAgent.sidebar.sessionMenu.openInExplorer')}
onSelect={handleShowInExplorer}
/>
) : null;

/* ---- 移动到项目 / 对话 ----
* 与 CCAgentSidebarUpper.handleMoveSession 同款守卫(远程不支持 / 执行中 /
* 被 IM 接管中拦截)与乐观更新 + 失败回滚;不含 sidebar 的目标项目节点自动
Expand Down Expand Up @@ -529,7 +558,7 @@ export function SessionContentHeader({
)}

{!isEditing && (
<DropdownMenu>
<DropdownMenu open={sessionMenuOpen} onOpenChange={setSessionMenuOpen}>
<DropdownMenuTrigger asChild>
<button
className={cn(
Expand Down Expand Up @@ -570,6 +599,7 @@ export function SessionContentHeader({
>
{t('ccAgent.sidebar.sessionMenu.unarchive')}
</DropdownMenuItem>
{showInExplorerMenuItem}
{canExportShare && (
<DropdownMenuItem
onSelect={() => setShareExportOpen(true)}
Expand Down Expand Up @@ -602,6 +632,7 @@ export function SessionContentHeader({
>
{t('ccAgent.sidebar.sessionMenu.rename')}
</DropdownMenuItem>
{showInExplorerMenuItem}
<DropdownMenuItem
onSelect={() => void handleCopyDeepLink()}
className={MENU_ITEM_CLASS}
Expand Down Expand Up @@ -670,6 +701,7 @@ export function SessionContentHeader({
</DropdownMenuSub>
)}
<DropdownMenuSeparator className={MENU_SEPARATOR_CLASS} />
{showInExplorerMenuItem}
<DropdownMenuItem
onSelect={() => void handleCopyDeepLink()}
className={MENU_ITEM_CLASS}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ import { RemoteProjectIcon } from './RemoteProjectIcon';
import { isRemoteSessionWriteBlocked } from '../lib/remoteSessionWriteGuard';
import { useSessionBoundSchedules, scheduleFocusPath } from '@/features/scheduler/lib/scheduleSessionBinding';
import { loadScheduleSidebarIndexRuns } from '@/features/scheduler/lib/scheduleSidebarIndexRuns';
import { ShowInExplorerMenuItem } from './ShowInExplorerMenuItem';

const log = createLogger('SessionCard');

Expand Down Expand Up @@ -340,6 +341,27 @@ export function SessionCard({
void window.electronAPI.maker.openSessionInNewWindow(session.id);
}, [remoteWritesBlocked, session.id, t]);

// 仅本地项目会话允许把 workingDir 交给系统文件管理器;远程会话的路径属于
// 被控端,不能在控制端误打开同名本地路径。
const canShowInExplorer =
session.workspaceKind === 'project' &&
Boolean(session.workingDir) &&
!session.remoteHostId &&
!session.deviceLinkDeviceId;
const handleShowInExplorerSelect = useCallback(async () => {
if (!canShowInExplorer || !session.workingDir) return;
setMenuPos(null);
try {
const result = await window.electronAPI.openPath(session.workingDir);
if (!result.success) {
toast.error(result.error || t('ccAgent.common.openFolderFailed'));
}
} catch (err) {
log.error('[show in explorer]', err);
toast.error(t('ccAgent.common.openFolderFailed'));
}
}, [canShowInExplorer, session.workingDir, t]);

const handleAutomationIconClick = useCallback(async (e: React.MouseEvent) => {
e.stopPropagation();
try {
Expand Down Expand Up @@ -402,6 +424,14 @@ export function SessionCard({
</DropdownMenuSub>
) : null;

const showInExplorerMenuItem = canShowInExplorer ? (
<ShowInExplorerMenuItem
enabled={menuPos !== null}
label={t('ccAgent.sidebar.sessionMenu.openInExplorer')}
onSelect={handleShowInExplorerSelect}
/>
) : null;

const statusIconNode = (
<span className={CARD_TITLE_STATUS_SLOT_CLASS} aria-hidden>
<SessionStatusIcon
Expand Down Expand Up @@ -832,6 +862,7 @@ export function SessionCard({
>
{t('ccAgent.sidebar.sessionMenu.unarchive')}
</DropdownMenuItem>
{showInExplorerMenuItem}
{copySessionIdSubmenu}
<DropdownMenuSeparator className={MENU_SEPARATOR_CLASS} />
<DropdownMenuItem
Expand All @@ -851,6 +882,7 @@ export function SessionCard({
>
{t('ccAgent.sidebar.sessionMenu.rename')}
</DropdownMenuItem>
{showInExplorerMenuItem}
{copySessionIdSubmenu}
<DropdownMenuSeparator className={MENU_SEPARATOR_CLASS} />
<DropdownMenuItem
Expand Down Expand Up @@ -879,6 +911,7 @@ export function SessionCard({
</DropdownMenuItem>
{moveToProjectSubmenu}
<DropdownMenuSeparator className={MENU_SEPARATOR_CLASS} />
{showInExplorerMenuItem}
{copySessionIdSubmenu}
<DropdownMenuItem
disabled={remoteWritesBlocked}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ import { useSessionAttentionUrgency } from '../contexts/SessionAttentionUrgencyC
import { useRemoteSessionActivity } from '@/features/device-link/remoteSessionActivityStore';
import { resolveSidebarRightStatus } from './sidebarRightStatus';
import { AutomationTimerIcon } from './AutomationTimerIcon';
import { ShowInExplorerMenuItem } from './ShowInExplorerMenuItem';

// Module-level dedup cache for loadScheduleSidebarIndexRuns.
// When many ungrouped automation rows mount simultaneously they all need the
Expand Down Expand Up @@ -522,6 +523,27 @@ export const SessionItem = memo(function SessionItem({
void window.electronAPI.maker.openSessionInNewWindow(session.id);
}, [remoteWritesBlocked, session.id, t]);

// 仅本地项目会话允许把 workingDir 交给系统文件管理器;远程会话的路径属于
// 被控端,不能在控制端误打开同名本地路径。
const canShowInExplorer =
session.workspaceKind === 'project' &&
Boolean(session.workingDir) &&
!session.remoteHostId &&
!session.deviceLinkDeviceId;
const handleShowInExplorerSelect = useCallback(async () => {
if (!canShowInExplorer || !session.workingDir) return;
setMenuPos(null);
try {
const result = await window.electronAPI.openPath(session.workingDir);
if (!result.success) {
toast.error(result.error || t('ccAgent.common.openFolderFailed'));
}
} catch (err) {
log.error('[show in explorer]', err);
toast.error(t('ccAgent.common.openFolderFailed'));
}
}, [canShowInExplorer, session.workingDir, t]);

// 复制 cindy://session/<id> 深度链接到剪贴板。三个变体(标准/Pinned/Archived/Draft)
// 共用此 handler — sessionId 始终存在(draft 也是 DB-backed 的 Session row)。
// 远程会话把归属设备冻进 `?device=`:发送时的引用解析不再依赖被控端此刻在线。
Expand Down Expand Up @@ -566,6 +588,14 @@ export const SessionItem = memo(function SessionItem({
</DropdownMenuItem>
) : null;

const showInExplorerMenuItem = canShowInExplorer ? (
<ShowInExplorerMenuItem
enabled={menuPos !== null}
label={t('ccAgent.sidebar.sessionMenu.openInExplorer')}
onSelect={handleShowInExplorerSelect}
/>
) : null;

const moveToProjectSubmenu = canMoveToProject ? (
<DropdownMenuSub>
<DropdownMenuSubTrigger className={MENU_ROW_CLASS}>
Expand Down Expand Up @@ -964,6 +994,7 @@ export const SessionItem = memo(function SessionItem({
>
{t('ccAgent.sidebar.sessionMenu.unarchive')}
</DropdownMenuItem>
{showInExplorerMenuItem}
{exportShareMenuItem}
{copySessionIdSubmenu}
<DropdownMenuSeparator className={MENU_SEPARATOR_CLASS} />
Expand All @@ -985,6 +1016,7 @@ export const SessionItem = memo(function SessionItem({
>
{t('ccAgent.sidebar.sessionMenu.rename')}
</DropdownMenuItem>
{showInExplorerMenuItem}
{copySessionIdSubmenu}
<DropdownMenuSeparator className={MENU_SEPARATOR_CLASS} />
<DropdownMenuItem
Expand Down Expand Up @@ -1016,6 +1048,7 @@ export const SessionItem = memo(function SessionItem({
</DropdownMenuItem>
{moveToProjectSubmenu}
<DropdownMenuSeparator className={MENU_SEPARATOR_CLASS} />
{showInExplorerMenuItem}
{copySessionIdSubmenu}
<DropdownMenuItem
disabled={remoteWritesBlocked}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { DropdownMenuItem } from '@/components/ui/dropdown-menu';
import { useAppShortcut, useAppShortcutDisplay } from '@/hooks/useAppShortcut';

import { MENU_ITEM_CLASS } from './menuStyles';

interface ShowInExplorerMenuItemProps {
enabled: boolean;
label: string;
onSelect: () => void | Promise<void>;
}

/**
* 本地项目菜单共用的“在文件管理器中打开”。该组件只会随已打开的
* DropdownMenuContent 挂载,因此快捷键天然只归属于当前菜单。
*/
export function ShowInExplorerMenuItem({ enabled, label, onSelect }: ShowInExplorerMenuItemProps) {
const shortcut = useAppShortcutDisplay('show-in-explorer');

useAppShortcut(
'show-in-explorer',
() => {
void onSelect();
return true;
},
{ enabled },
);

return (
<DropdownMenuItem onSelect={() => void onSelect()} className={MENU_ITEM_CLASS}>
<span className="flex-1">{label}</span>
{shortcut && (
<kbd className="ml-auto shrink-0 text-[10px] text-[var(--cmd-palette-item-meta)]">
{shortcut}
</kbd>
)}
</DropdownMenuItem>
);
}
Loading