chore: phase 3 refactorings
This commit is contained in:
11
TODO.md
11
TODO.md
@@ -187,9 +187,10 @@ Pure tab-policy helpers that decide:
|
|||||||
## Phase 3 — Centralize Editor/Tab Management (expanded)
|
## Phase 3 — Centralize Editor/Tab Management (expanded)
|
||||||
- [x] Implement shared tab policy layer for singleton tool tabs.
|
- [x] Implement shared tab policy layer for singleton tool tabs.
|
||||||
- [x] Centralize singleton tab policies (`settings`, `tags`, `style`, `documentation`, `metadata-diff`, `site-validation`).
|
- [x] Centralize singleton tab policies (`settings`, `tags`, `style`, `documentation`, `metadata-diff`, `site-validation`).
|
||||||
- Centralize transient/pin lifecycle rules (including promotion behavior).
|
- [x] Centralize post/media transient-vs-pinned open intent rules via shared entity-tab policy helper.
|
||||||
- Remove duplicated tab-open logic from sidebars/nav components where possible.
|
- [x] Remove duplicated tab-open logic from sidebars/nav/components for centralized policy-covered tab types.
|
||||||
- [x] Ensure site-validation tab reopens with persisted last report and refreshes only on explicit validation trigger.
|
- [x] Ensure site-validation tab reopens with persisted last report and refreshes only on explicit validation trigger.
|
||||||
|
- [x] Centralize multi-instance tab opening policies for `chat`, `import`, and `git-diff` (file/commit resources).
|
||||||
|
|
||||||
### Editor Tab Management Variations (current analysis)
|
### Editor Tab Management Variations (current analysis)
|
||||||
1. **Singleton, pinned tool tabs**
|
1. **Singleton, pinned tool tabs**
|
||||||
@@ -253,4 +254,8 @@ Pure tab-policy helpers that decide:
|
|||||||
- [x] Phase 3 slice complete: added singleton tab policy helper and migrated menu/sidebar singleton tool openings to it.
|
- [x] Phase 3 slice complete: added singleton tab policy helper and migrated menu/sidebar singleton tool openings to it.
|
||||||
- [x] Phase 3 slice complete: site validation report is persisted per project and reloaded by `SiteValidationView`.
|
- [x] Phase 3 slice complete: site validation report is persisted per project and reloaded by `SiteValidationView`.
|
||||||
- [x] Phase 3 slice complete: site validation view refreshes via explicit `bds:site-validation-updated` event (no auto-validate on mount).
|
- [x] Phase 3 slice complete: site validation view refreshes via explicit `bds:site-validation-updated` event (no auto-validate on mount).
|
||||||
- [ ] Next implementation slice: Phase 3 continuation (centralize non-singleton tab lifecycle/promotion rules).
|
- [x] Phase 3 continuation slice complete: added shared entity tab intent helper (`preview`/`pin`) for `post`/`media`.
|
||||||
|
- [x] Phase 3 continuation slice complete: migrated post/media open paths in Sidebar, Editor, LinkedMediaPanel, and Panel to shared entity-tab policy.
|
||||||
|
- [x] Phase 3 completion slice: centralized `chat` and `import` tab-open specs and migrated sidebar call sites.
|
||||||
|
- [x] Phase 3 completion slice: centralized `git-diff` file/commit ID/spec/open helpers and reused shared parsing in `TabBar`.
|
||||||
|
- [ ] Next implementation slice: Phase 4 (editor routing registry).
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { SiteValidationView } from '../SiteValidationView';
|
|||||||
import { AutoSaveManager, getContrastColor } from '../../utils';
|
import { AutoSaveManager, getContrastColor } from '../../utils';
|
||||||
import { InsertModal } from '../InsertModal';
|
import { InsertModal } from '../InsertModal';
|
||||||
import { AISuggestionsModal, AISuggestions } from '../AISuggestionsModal/AISuggestionsModal';
|
import { AISuggestionsModal, AISuggestions } from '../AISuggestionsModal/AISuggestionsModal';
|
||||||
|
import { openEntityTab } from '../../navigation/tabPolicy';
|
||||||
import { useI18n } from '../../i18n';
|
import { useI18n } from '../../i18n';
|
||||||
import './Editor.css';
|
import './Editor.css';
|
||||||
|
|
||||||
@@ -1102,7 +1103,7 @@ const MediaEditor: React.FC<{ mediaId: string }> = ({ mediaId }) => {
|
|||||||
|
|
||||||
// Handle click on a post to navigate to it
|
// Handle click on a post to navigate to it
|
||||||
const handlePostClick = (postId: string) => {
|
const handlePostClick = (postId: string) => {
|
||||||
openTab({ type: 'post', id: postId, isTransient: true });
|
openEntityTab(openTab, 'post', postId, 'preview');
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get unlinked posts for picker, filtered by search
|
// Get unlinked posts for picker, filtered by search
|
||||||
@@ -1680,12 +1681,12 @@ const Dashboard: React.FC = () => {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
useAppStore.getState().setActiveView('posts');
|
useAppStore.getState().setActiveView('posts');
|
||||||
useAppStore.getState().setSelectedPost(post.id);
|
useAppStore.getState().setSelectedPost(post.id);
|
||||||
useAppStore.getState().openTab({ type: 'post', id: post.id, isTransient: true });
|
openEntityTab(useAppStore.getState().openTab, 'post', post.id, 'preview');
|
||||||
}}
|
}}
|
||||||
onDoubleClick={() => {
|
onDoubleClick={() => {
|
||||||
useAppStore.getState().setActiveView('posts');
|
useAppStore.getState().setActiveView('posts');
|
||||||
useAppStore.getState().setSelectedPost(post.id);
|
useAppStore.getState().setSelectedPost(post.id);
|
||||||
useAppStore.getState().openTab({ type: 'post', id: post.id, isTransient: false });
|
openEntityTab(useAppStore.getState().openTab, 'post', post.id, 'pin');
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="recent-post-title">{post.title || tr('editor.untitled')}</span>
|
<span className="recent-post-title">{post.title || tr('editor.untitled')}</span>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { useAppStore } from '../../store';
|
import { useAppStore } from '../../store';
|
||||||
|
import { openGitDiffCommitTab, openGitDiffFileTab } from '../../navigation/tabPolicy';
|
||||||
import { useI18n } from '../../i18n';
|
import { useI18n } from '../../i18n';
|
||||||
import type { GitInitProgress, GitHistoryEntry, GitRemoteStateDto } from '../../../main/shared/electronApi';
|
import type { GitInitProgress, GitHistoryEntry, GitRemoteStateDto } from '../../../main/shared/electronApi';
|
||||||
import './GitSidebar.css';
|
import './GitSidebar.css';
|
||||||
@@ -125,9 +126,6 @@ export const GitSidebar: React.FC = () => {
|
|||||||
[tr],
|
[tr],
|
||||||
);
|
);
|
||||||
|
|
||||||
const getDiffTabId = (filePath: string): string => `git-diff:${filePath}`;
|
|
||||||
const getCommitDiffTabId = (commitHash: string): string => `git-diff:commit:${commitHash}`;
|
|
||||||
|
|
||||||
const getActionProgressMessage = (action: 'fetch' | 'pull' | 'push' | 'prune-lfs' | 'commit'): string => {
|
const getActionProgressMessage = (action: 'fetch' | 'pull' | 'push' | 'prune-lfs' | 'commit'): string => {
|
||||||
if (action === 'push') {
|
if (action === 'push') {
|
||||||
return tr('gitSidebar.progress.pushingRemote');
|
return tr('gitSidebar.progress.pushingRemote');
|
||||||
@@ -156,22 +154,14 @@ export const GitSidebar: React.FC = () => {
|
|||||||
|
|
||||||
const openDiffTab = useCallback(
|
const openDiffTab = useCallback(
|
||||||
(filePath: string, isTransient: boolean) => {
|
(filePath: string, isTransient: boolean) => {
|
||||||
openTab({
|
openGitDiffFileTab(openTab, filePath, isTransient ? 'preview' : 'pin');
|
||||||
type: 'git-diff',
|
|
||||||
id: getDiffTabId(filePath),
|
|
||||||
isTransient,
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
[openTab],
|
[openTab],
|
||||||
);
|
);
|
||||||
|
|
||||||
const openCommitDiffTab = useCallback(
|
const openCommitDiffTab = useCallback(
|
||||||
(commitHash: string, isTransient: boolean) => {
|
(commitHash: string, isTransient: boolean) => {
|
||||||
openTab({
|
openGitDiffCommitTab(openTab, commitHash, isTransient ? 'preview' : 'pin');
|
||||||
type: 'git-diff',
|
|
||||||
id: getCommitDiffTabId(commitHash),
|
|
||||||
isTransient,
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
[openTab],
|
[openTab],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
|
|
||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import { useAppStore, MediaData } from '../../store';
|
import { useAppStore, MediaData } from '../../store';
|
||||||
|
import { openEntityTab } from '../../navigation/tabPolicy';
|
||||||
import { showToast } from '../Toast';
|
import { showToast } from '../Toast';
|
||||||
import { useI18n } from '../../i18n';
|
import { useI18n } from '../../i18n';
|
||||||
import './LinkedMediaPanel.css';
|
import './LinkedMediaPanel.css';
|
||||||
@@ -197,7 +198,7 @@ export const LinkedMediaPanel: React.FC<LinkedMediaPanelProps> = ({
|
|||||||
|
|
||||||
// Handle click on media item to open media viewer
|
// Handle click on media item to open media viewer
|
||||||
const handleMediaClick = (mediaId: string) => {
|
const handleMediaClick = (mediaId: string) => {
|
||||||
useAppStore.getState().openTab({ type: 'media', id: mediaId, isTransient: true });
|
openEntityTab(useAppStore.getState().openTab, 'media', mediaId, 'preview');
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get thumbnail URL for a media item
|
// Get thumbnail URL for a media item
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useAppStore } from '../../store';
|
import { useAppStore } from '../../store';
|
||||||
import type { TaskProgress } from '../../../main/shared/electronApi';
|
import type { TaskProgress } from '../../../main/shared/electronApi';
|
||||||
|
import { openEntityTab } from '../../navigation/tabPolicy';
|
||||||
import { useI18n } from '../../i18n';
|
import { useI18n } from '../../i18n';
|
||||||
import './Panel.css';
|
import './Panel.css';
|
||||||
|
|
||||||
@@ -296,7 +297,7 @@ export const Panel: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handlePostLinkClick = (postId: string) => {
|
const handlePostLinkClick = (postId: string) => {
|
||||||
openTab({ type: 'post', id: postId, isTransient: false });
|
openEntityTab(openTab, 'post', postId, 'pin');
|
||||||
setSelectedPost(postId);
|
setSelectedPost(postId);
|
||||||
setActiveView('posts');
|
setActiveView('posts');
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { scrollToSettingsSection, SettingsCategory } from '../SettingsView/Setti
|
|||||||
import { scrollToTagsSection, TagsCategory } from '../TagsView';
|
import { scrollToTagsSection, TagsCategory } from '../TagsView';
|
||||||
import { activateSidebarSection } from '../../navigation/sectionActivation';
|
import { activateSidebarSection } from '../../navigation/sectionActivation';
|
||||||
import { getPersistedSidebarSection, setPersistedSidebarSection } from '../../navigation/sidebarUiPersistence';
|
import { getPersistedSidebarSection, setPersistedSidebarSection } from '../../navigation/sidebarUiPersistence';
|
||||||
import { openSingletonToolTab } from '../../navigation/tabPolicy';
|
import { openChatTab, openEntityTab, openImportTab, openSingletonToolTab } from '../../navigation/tabPolicy';
|
||||||
import type { SidebarView } from '../../navigation/sidebarViewRegistry';
|
import type { SidebarView } from '../../navigation/sidebarViewRegistry';
|
||||||
import { useI18n } from '../../i18n';
|
import { useI18n } from '../../i18n';
|
||||||
import './Sidebar.css';
|
import './Sidebar.css';
|
||||||
@@ -805,11 +805,11 @@ const PostsList: React.FC<PostsListProps> = ({ mode, isActive }) => {
|
|||||||
|
|
||||||
// Click handlers for tabs
|
// Click handlers for tabs
|
||||||
const handlePostClick = (postId: string) => {
|
const handlePostClick = (postId: string) => {
|
||||||
openTab({ type: 'post', id: postId, isTransient: true });
|
openEntityTab(openTab, 'post', postId, 'preview');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePostDoubleClick = (postId: string) => {
|
const handlePostDoubleClick = (postId: string) => {
|
||||||
openTab({ type: 'post', id: postId, isTransient: false });
|
openEntityTab(openTab, 'post', postId, 'pin');
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1104,11 +1104,11 @@ const MediaList: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleMediaClick = (mediaId: string) => {
|
const handleMediaClick = (mediaId: string) => {
|
||||||
openTab({ type: 'media', id: mediaId, isTransient: true });
|
openEntityTab(openTab, 'media', mediaId, 'preview');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMediaDoubleClick = (mediaId: string) => {
|
const handleMediaDoubleClick = (mediaId: string) => {
|
||||||
openTab({ type: 'media', id: mediaId, isTransient: false });
|
openEntityTab(openTab, 'media', mediaId, 'pin');
|
||||||
};
|
};
|
||||||
|
|
||||||
// Determine which media to display
|
// Determine which media to display
|
||||||
@@ -1430,7 +1430,7 @@ const ChatList: React.FC = () => {
|
|||||||
const conversation = await window.electronAPI?.chat.createConversation();
|
const conversation = await window.electronAPI?.chat.createConversation();
|
||||||
if (conversation) {
|
if (conversation) {
|
||||||
setConversations(prev => [conversation, ...prev]);
|
setConversations(prev => [conversation, ...prev]);
|
||||||
openTab({ type: 'chat', id: conversation.id, isTransient: false });
|
openChatTab(openTab, conversation.id);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to create conversation:', error);
|
console.error('Failed to create conversation:', error);
|
||||||
@@ -1439,7 +1439,7 @@ const ChatList: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenChat = (conversationId: string) => {
|
const handleOpenChat = (conversationId: string) => {
|
||||||
openTab({ type: 'chat', id: conversationId, isTransient: false });
|
openChatTab(openTab, conversationId);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteChat = async (conversationId: string) => {
|
const handleDeleteChat = async (conversationId: string) => {
|
||||||
@@ -1580,7 +1580,7 @@ const ImportList: React.FC = () => {
|
|||||||
const def = await window.electronAPI?.importDefinitions.create();
|
const def = await window.electronAPI?.importDefinitions.create();
|
||||||
if (def) {
|
if (def) {
|
||||||
setDefinitions(prev => [def, ...prev]);
|
setDefinitions(prev => [def, ...prev]);
|
||||||
openTab({ type: 'import', id: def.id, isTransient: false });
|
openImportTab(openTab, def.id);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to create import definition:', error);
|
console.error('Failed to create import definition:', error);
|
||||||
@@ -1589,7 +1589,7 @@ const ImportList: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenDefinition = (definitionId: string) => {
|
const handleOpenDefinition = (definitionId: string) => {
|
||||||
openTab({ type: 'import', id: definitionId, isTransient: false });
|
openImportTab(openTab, definitionId);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteDefinition = async (e: React.MouseEvent, definitionId: string) => {
|
const handleDeleteDefinition = async (e: React.MouseEvent, definitionId: string) => {
|
||||||
|
|||||||
@@ -1,22 +1,11 @@
|
|||||||
import React, { useRef, useState, useEffect, useCallback } from 'react';
|
import React, { useRef, useState, useEffect, useCallback } from 'react';
|
||||||
import { useAppStore, Tab } from '../../store';
|
import { useAppStore, Tab } from '../../store';
|
||||||
|
import { parseGitDiffTabId } from '../../navigation/tabPolicy';
|
||||||
import { useI18n } from '../../i18n';
|
import { useI18n } from '../../i18n';
|
||||||
import './TabBar.css';
|
import './TabBar.css';
|
||||||
|
|
||||||
const MAX_CHAT_TITLE_LENGTH = 18;
|
const MAX_CHAT_TITLE_LENGTH = 18;
|
||||||
|
|
||||||
function getGitDiffResource(tabId: string): string {
|
|
||||||
return tabId.startsWith('git-diff:') ? tabId.slice('git-diff:'.length) : tabId;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCommitHashFromGitDiffTabId(tabId: string): string | null {
|
|
||||||
const resource = getGitDiffResource(tabId);
|
|
||||||
if (!resource.startsWith('commit:')) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return resource.slice('commit:'.length);
|
|
||||||
}
|
|
||||||
|
|
||||||
const getTabTitle = (
|
const getTabTitle = (
|
||||||
tab: Tab,
|
tab: Tab,
|
||||||
postTitles: Map<string, string>,
|
postTitles: Map<string, string>,
|
||||||
@@ -27,8 +16,7 @@ const getTabTitle = (
|
|||||||
tr: (key: string, vars?: Record<string, string | number>) => string,
|
tr: (key: string, vars?: Record<string, string | number>) => string,
|
||||||
): string => {
|
): string => {
|
||||||
if (tab.type === 'git-diff') {
|
if (tab.type === 'git-diff') {
|
||||||
const filePath = getGitDiffResource(tab.id);
|
const { resource: filePath, commitHash } = parseGitDiffTabId(tab.id);
|
||||||
const commitHash = getCommitHashFromGitDiffTabId(tab.id);
|
|
||||||
if (commitHash) {
|
if (commitHash) {
|
||||||
const commitTitle = commitTitles.get(commitHash);
|
const commitTitle = commitTitles.get(commitHash);
|
||||||
if (commitTitle) {
|
if (commitTitle) {
|
||||||
@@ -380,7 +368,7 @@ export const TabBar: React.FC = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const commitHashes = tabs
|
const commitHashes = tabs
|
||||||
.filter((tab) => tab.type === 'git-diff')
|
.filter((tab) => tab.type === 'git-diff')
|
||||||
.map((tab) => getCommitHashFromGitDiffTabId(tab.id))
|
.map((tab) => parseGitDiffTabId(tab.id).commitHash)
|
||||||
.filter((hash): hash is string => Boolean(hash));
|
.filter((hash): hash is string => Boolean(hash));
|
||||||
|
|
||||||
if (commitHashes.length === 0 || !activeProject) {
|
if (commitHashes.length === 0 || !activeProject) {
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ export interface CanonicalTabSpec {
|
|||||||
isTransient: boolean;
|
isTransient: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type EntityTabType = 'post' | 'media';
|
||||||
|
export type EntityTabOpenIntent = 'preview' | 'pin';
|
||||||
|
export type GitDiffResourceOpenIntent = 'preview' | 'pin';
|
||||||
|
|
||||||
const SINGLETON_TOOL_TAB_REGISTRY: Record<SingletonToolTabKey, CanonicalTabSpec> = {
|
const SINGLETON_TOOL_TAB_REGISTRY: Record<SingletonToolTabKey, CanonicalTabSpec> = {
|
||||||
settings: { type: 'settings', id: 'settings', isTransient: false },
|
settings: { type: 'settings', id: 'settings', isTransient: false },
|
||||||
tags: { type: 'tags', id: 'tags', isTransient: false },
|
tags: { type: 'tags', id: 'tags', isTransient: false },
|
||||||
@@ -33,3 +37,112 @@ export function openSingletonToolTab(
|
|||||||
): void {
|
): void {
|
||||||
openTab(getSingletonToolTabSpec(key));
|
openTab(getSingletonToolTabSpec(key));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getEntityTabSpec(
|
||||||
|
type: EntityTabType,
|
||||||
|
id: string,
|
||||||
|
intent: EntityTabOpenIntent,
|
||||||
|
): CanonicalTabSpec {
|
||||||
|
return {
|
||||||
|
type,
|
||||||
|
id,
|
||||||
|
isTransient: intent === 'preview',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openEntityTab(
|
||||||
|
openTab: (tab: CanonicalTabSpec) => void,
|
||||||
|
type: EntityTabType,
|
||||||
|
id: string,
|
||||||
|
intent: EntityTabOpenIntent,
|
||||||
|
): void {
|
||||||
|
openTab(getEntityTabSpec(type, id, intent));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getChatTabSpec(conversationId: string): CanonicalTabSpec {
|
||||||
|
return {
|
||||||
|
type: 'chat',
|
||||||
|
id: conversationId,
|
||||||
|
isTransient: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openChatTab(
|
||||||
|
openTab: (tab: CanonicalTabSpec) => void,
|
||||||
|
conversationId: string,
|
||||||
|
): void {
|
||||||
|
openTab(getChatTabSpec(conversationId));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getImportTabSpec(definitionId: string): CanonicalTabSpec {
|
||||||
|
return {
|
||||||
|
type: 'import',
|
||||||
|
id: definitionId,
|
||||||
|
isTransient: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openImportTab(
|
||||||
|
openTab: (tab: CanonicalTabSpec) => void,
|
||||||
|
definitionId: string,
|
||||||
|
): void {
|
||||||
|
openTab(getImportTabSpec(definitionId));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getGitDiffFileTabId(filePath: string): string {
|
||||||
|
return `git-diff:${filePath}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getGitDiffCommitTabId(commitHash: string): string {
|
||||||
|
return `git-diff:commit:${commitHash}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getGitDiffFileTabSpec(
|
||||||
|
filePath: string,
|
||||||
|
intent: GitDiffResourceOpenIntent,
|
||||||
|
): CanonicalTabSpec {
|
||||||
|
return {
|
||||||
|
type: 'git-diff',
|
||||||
|
id: getGitDiffFileTabId(filePath),
|
||||||
|
isTransient: intent === 'preview',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getGitDiffCommitTabSpec(
|
||||||
|
commitHash: string,
|
||||||
|
intent: GitDiffResourceOpenIntent,
|
||||||
|
): CanonicalTabSpec {
|
||||||
|
return {
|
||||||
|
type: 'git-diff',
|
||||||
|
id: getGitDiffCommitTabId(commitHash),
|
||||||
|
isTransient: intent === 'preview',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openGitDiffFileTab(
|
||||||
|
openTab: (tab: CanonicalTabSpec) => void,
|
||||||
|
filePath: string,
|
||||||
|
intent: GitDiffResourceOpenIntent,
|
||||||
|
): void {
|
||||||
|
openTab(getGitDiffFileTabSpec(filePath, intent));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openGitDiffCommitTab(
|
||||||
|
openTab: (tab: CanonicalTabSpec) => void,
|
||||||
|
commitHash: string,
|
||||||
|
intent: GitDiffResourceOpenIntent,
|
||||||
|
): void {
|
||||||
|
openTab(getGitDiffCommitTabSpec(commitHash, intent));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseGitDiffTabId(tabId: string): { resource: string; commitHash: string | null } {
|
||||||
|
const resource = tabId.startsWith('git-diff:') ? tabId.slice('git-diff:'.length) : tabId;
|
||||||
|
if (!resource.startsWith('commit:')) {
|
||||||
|
return { resource, commitHash: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
resource,
|
||||||
|
commitHash: resource.slice('commit:'.length),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,19 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { getSingletonToolTabSpec, openSingletonToolTab } from '../../../src/renderer/navigation/tabPolicy';
|
import {
|
||||||
|
getChatTabSpec,
|
||||||
|
getEntityTabSpec,
|
||||||
|
getGitDiffCommitTabSpec,
|
||||||
|
getGitDiffFileTabSpec,
|
||||||
|
getImportTabSpec,
|
||||||
|
parseGitDiffTabId,
|
||||||
|
openChatTab,
|
||||||
|
getSingletonToolTabSpec,
|
||||||
|
openEntityTab,
|
||||||
|
openGitDiffCommitTab,
|
||||||
|
openGitDiffFileTab,
|
||||||
|
openImportTab,
|
||||||
|
openSingletonToolTab,
|
||||||
|
} from '../../../src/renderer/navigation/tabPolicy';
|
||||||
|
|
||||||
describe('tabPolicy', () => {
|
describe('tabPolicy', () => {
|
||||||
it('provides canonical singleton tab specs', () => {
|
it('provides canonical singleton tab specs', () => {
|
||||||
@@ -22,4 +36,97 @@ describe('tabPolicy', () => {
|
|||||||
|
|
||||||
expect(captured).toEqual({ type: 'site-validation', id: 'site-validation', isTransient: false });
|
expect(captured).toEqual({ type: 'site-validation', id: 'site-validation', isTransient: false });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('provides canonical entity tab spec for preview and pin intents', () => {
|
||||||
|
expect(getEntityTabSpec('post', 'post-1', 'preview')).toEqual({
|
||||||
|
type: 'post',
|
||||||
|
id: 'post-1',
|
||||||
|
isTransient: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getEntityTabSpec('media', 'media-1', 'pin')).toEqual({
|
||||||
|
type: 'media',
|
||||||
|
id: 'media-1',
|
||||||
|
isTransient: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens entity tabs from canonical policy', () => {
|
||||||
|
let captured: { type: string; id: string; isTransient: boolean } | null = null;
|
||||||
|
const openTab = (tab: { type: string; id: string; isTransient: boolean }) => {
|
||||||
|
captured = tab;
|
||||||
|
};
|
||||||
|
|
||||||
|
openEntityTab(openTab, 'post', 'post-2', 'pin');
|
||||||
|
|
||||||
|
expect(captured).toEqual({ type: 'post', id: 'post-2', isTransient: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('provides canonical chat and import tab specs', () => {
|
||||||
|
expect(getChatTabSpec('conversation-1')).toEqual({
|
||||||
|
type: 'chat',
|
||||||
|
id: 'conversation-1',
|
||||||
|
isTransient: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getImportTabSpec('definition-1')).toEqual({
|
||||||
|
type: 'import',
|
||||||
|
id: 'definition-1',
|
||||||
|
isTransient: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens canonical chat and import tabs', () => {
|
||||||
|
const opened: Array<{ type: string; id: string; isTransient: boolean }> = [];
|
||||||
|
const openTab = (tab: { type: string; id: string; isTransient: boolean }) => {
|
||||||
|
opened.push(tab);
|
||||||
|
};
|
||||||
|
|
||||||
|
openChatTab(openTab, 'conversation-2');
|
||||||
|
openImportTab(openTab, 'definition-2');
|
||||||
|
|
||||||
|
expect(opened).toEqual([
|
||||||
|
{ type: 'chat', id: 'conversation-2', isTransient: false },
|
||||||
|
{ type: 'import', id: 'definition-2', isTransient: false },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds and parses git-diff file and commit tab specs', () => {
|
||||||
|
expect(getGitDiffFileTabSpec('posts/first.md', 'preview')).toEqual({
|
||||||
|
type: 'git-diff',
|
||||||
|
id: 'git-diff:posts/first.md',
|
||||||
|
isTransient: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getGitDiffCommitTabSpec('abc123def', 'pin')).toEqual({
|
||||||
|
type: 'git-diff',
|
||||||
|
id: 'git-diff:commit:abc123def',
|
||||||
|
isTransient: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(parseGitDiffTabId('git-diff:posts/first.md')).toEqual({
|
||||||
|
resource: 'posts/first.md',
|
||||||
|
commitHash: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(parseGitDiffTabId('git-diff:commit:abc123def')).toEqual({
|
||||||
|
resource: 'commit:abc123def',
|
||||||
|
commitHash: 'abc123def',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens git-diff file and commit tabs from shared policy', () => {
|
||||||
|
const opened: Array<{ type: string; id: string; isTransient: boolean }> = [];
|
||||||
|
const openTab = (tab: { type: string; id: string; isTransient: boolean }) => {
|
||||||
|
opened.push(tab);
|
||||||
|
};
|
||||||
|
|
||||||
|
openGitDiffFileTab(openTab, 'posts/second.md', 'preview');
|
||||||
|
openGitDiffCommitTab(openTab, 'def456', 'pin');
|
||||||
|
|
||||||
|
expect(opened).toEqual([
|
||||||
|
{ type: 'git-diff', id: 'git-diff:posts/second.md', isTransient: true },
|
||||||
|
{ type: 'git-diff', id: 'git-diff:commit:def456', isTransient: false },
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user