feat: proper sidebar and import persistence

This commit is contained in:
2026-02-13 14:09:54 +01:00
parent d88fb1d9fa
commit 9169f2a34c
16 changed files with 922 additions and 50 deletions

View File

@@ -66,8 +66,8 @@ export const ActivityBar: React.FC = () => {
// Check if chat sidebar is active (activeView === 'chat' and sidebar is visible)
const isChatActive = activeView === 'chat' && sidebarVisible;
// Check if import tab is currently active
const isImportTabActive = tabs.some(t => t.type === 'import' && t.id === activeTabId);
// Check if import sidebar is active
const isImportActive = activeView === 'import' && sidebarVisible;
// Handle view click - toggle sidebar if clicking on active view, otherwise switch view
const handleViewClick = (view: 'posts' | 'media' | 'chat') => {
@@ -106,8 +106,14 @@ export const ActivityBar: React.FC = () => {
};
const handleImportClick = () => {
// Open import as a dedicated (non-transient) tab
openTab({ type: 'import', id: 'import', isTransient: false });
if (activeView === 'import' && sidebarVisible) {
toggleSidebar();
} else {
setActiveView('import');
if (!sidebarVisible) {
toggleSidebar();
}
}
};
return (
@@ -142,9 +148,9 @@ export const ActivityBar: React.FC = () => {
<ChatIcon />
</button>
<button
className={`activity-bar-item ${isImportTabActive ? 'active' : ''}`}
className={`activity-bar-item ${isImportActive ? 'active' : ''}`}
onClick={handleImportClick}
title="Import Analysis"
title="Import (click again to toggle sidebar)"
>
<ImportIcon />
</button>

View File

@@ -1622,10 +1622,10 @@ export const Editor: React.FC = () => {
}
// Show import analysis if import tab is active
if (showImport) {
if (showImport && activeTabId) {
return (
<div className="editor">
<ImportAnalysisView />
<ImportAnalysisView key={activeTabId} definitionId={activeTabId} />
{renderErrorModal()}
{renderConfirmDeleteModal()}
</div>

View File

@@ -20,6 +20,28 @@
color: var(--vscode-foreground);
}
.import-definition-name {
background: transparent;
border: 1px solid transparent;
border-radius: 4px;
font-size: 18px;
font-weight: 600;
color: var(--vscode-foreground);
padding: 2px 6px;
margin: -2px -6px;
width: calc(100% + 12px);
outline: none;
}
.import-definition-name:hover {
border-color: var(--vscode-input-border, #3c3c3c);
}
.import-definition-name:focus {
border-color: var(--vscode-focusBorder, #007fd4);
background: var(--vscode-input-background, #1e1e1e);
}
.import-analysis-header p {
margin: 0;
font-size: 12px;

View File

@@ -1,5 +1,4 @@
import React, { useState, useCallback } from 'react';
import { useAppStore } from '../../store';
import React, { useState, useCallback, useEffect, useRef } from 'react';
import './ImportAnalysisView.css';
interface AnalysisReport {
@@ -53,43 +52,107 @@ interface TaxonomyItem {
existsInProject: boolean;
}
export const ImportAnalysisView: React.FC = () => {
const { importAnalysis, importAnalysisLoading, setImportAnalysis, setImportAnalysisLoading } = useAppStore();
const [uploadsFolder, setUploadsFolder] = useState<string | null>(null);
const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({});
interface ImportAnalysisViewProps {
definitionId: string;
}
const report = importAnalysis as AnalysisReport | null;
export const ImportAnalysisView: React.FC<ImportAnalysisViewProps> = ({ definitionId }) => {
const [name, setName] = useState('Untitled Import');
const [uploadsFolder, setUploadsFolder] = useState<string | null>(null);
const [wxrFilePath, setWxrFilePath] = useState<string | null>(null);
const [report, setReport] = useState<AnalysisReport | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isLoadingDefinition, setIsLoadingDefinition] = useState(true);
const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({});
const nameInputRef = useRef<HTMLInputElement>(null);
// Load definition on mount
useEffect(() => {
const load = async () => {
setIsLoadingDefinition(true);
try {
const def = await window.electronAPI?.importDefinitions.get(definitionId);
if (def) {
setName(def.name);
if (def.uploadsFolderPath) setUploadsFolder(def.uploadsFolderPath);
if (def.wxrFilePath) setWxrFilePath(def.wxrFilePath);
if (def.lastAnalysisResult) {
const parsed = typeof def.lastAnalysisResult === 'string'
? JSON.parse(def.lastAnalysisResult)
: def.lastAnalysisResult;
setReport(parsed as AnalysisReport);
}
}
} catch (error) {
console.error('Failed to load import definition:', error);
} finally {
setIsLoadingDefinition(false);
}
};
load();
}, [definitionId]);
const handleNameBlur = useCallback(async () => {
const trimmed = name.trim() || 'Untitled Import';
setName(trimmed);
await window.electronAPI?.importDefinitions.update(definitionId, { name: trimmed });
}, [definitionId, name]);
const handleSelectUploadsFolder = useCallback(async () => {
const folder = await window.electronAPI?.import.selectUploadsFolder();
if (folder) {
setUploadsFolder(folder);
await window.electronAPI?.importDefinitions.update(definitionId, { uploadsFolderPath: folder });
}
}, []);
}, [definitionId]);
const handleSelectAndAnalyze = useCallback(async () => {
setImportAnalysisLoading(true);
setImportAnalysis(null);
setIsLoading(true);
setReport(null);
try {
const result = await window.electronAPI?.import.selectAndAnalyze(uploadsFolder || undefined);
const result = await window.electronAPI?.import.selectAndAnalyze(uploadsFolder || undefined) as AnalysisReport | null;
if (result) {
setImportAnalysis(result);
setReport(result);
setWxrFilePath(result.sourceFile);
await window.electronAPI?.importDefinitions.update(definitionId, {
lastAnalysisResult: JSON.stringify(result),
wxrFilePath: result.sourceFile,
});
}
} catch (error) {
console.error('Import analysis failed:', error);
} finally {
setImportAnalysisLoading(false);
setIsLoading(false);
}
}, [uploadsFolder, setImportAnalysis, setImportAnalysisLoading]);
}, [definitionId, uploadsFolder]);
const toggleSection = useCallback((section: string) => {
setExpandedSections(prev => ({ ...prev, [section]: !prev[section] }));
}, []);
if (isLoadingDefinition) {
return (
<div className="import-analysis">
<div className="import-loading">
<div className="import-spinner" />
Loading import definition...
</div>
</div>
);
}
return (
<div className="import-analysis">
<div className="import-analysis-header">
<h2>Import Analysis</h2>
<input
ref={nameInputRef}
className="import-definition-name"
value={name}
onChange={(e) => setName(e.target.value)}
onBlur={handleNameBlur}
onKeyDown={(e) => { if (e.key === 'Enter') nameInputRef.current?.blur(); }}
placeholder="Import name..."
/>
<p>Select a WordPress export file (WXR) and an uploads folder to analyze what would be imported.</p>
</div>
@@ -103,27 +166,27 @@ export const ImportAnalysisView: React.FC = () => {
</div>
<div className="import-file-row">
<label>WXR File</label>
<div className={`import-file-path ${!report ? 'placeholder' : ''}`}>
{report?.sourceFile || 'Select a file to analyze'}
<div className={`import-file-path ${!wxrFilePath ? 'placeholder' : ''}`}>
{wxrFilePath || report?.sourceFile || 'Select a file to analyze'}
</div>
<button
className="import-analyze-btn"
onClick={handleSelectAndAnalyze}
disabled={importAnalysisLoading}
disabled={isLoading}
>
{importAnalysisLoading ? 'Analyzing...' : 'Select & Analyze'}
{isLoading ? 'Analyzing...' : 'Select & Analyze'}
</button>
</div>
</div>
{importAnalysisLoading && (
{isLoading && (
<div className="import-loading">
<div className="import-spinner" />
Analyzing WXR file...
</div>
)}
{!report && !importAnalysisLoading && (
{!report && !isLoading && (
<div className="import-empty-state">
<svg width="48" height="48" viewBox="0 0 24 24" fill="currentColor">
<path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/>
@@ -132,7 +195,7 @@ export const ImportAnalysisView: React.FC = () => {
</div>
)}
{report && !importAnalysisLoading && (
{report && !isLoading && (
<>
<SiteInfoCard site={report.site} sourceFile={report.sourceFile} />
<StatCards report={report} />

View File

@@ -1,7 +1,7 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useAppStore, PostData, MediaData } from '../../store';
import { showToast } from '../Toast';
import type { ChatConversation } from '../../types/electron';
import type { ChatConversation, ImportDefinitionData } from '../../types/electron';
import './Sidebar.css';
// Tag data with color information
@@ -1268,6 +1268,127 @@ const ChatList: React.FC = () => {
);
};
const ImportList: React.FC = () => {
const { openTab, closeTab } = useAppStore();
const [definitions, setDefinitions] = useState<ImportDefinitionData[]>([]);
const [isLoading, setIsLoading] = useState(true);
const loadDefinitions = useCallback(async () => {
try {
const defs = await window.electronAPI?.importDefinitions.getAll();
if (defs) {
setDefinitions(defs);
}
} catch (error) {
console.error('Failed to load import definitions:', error);
}
}, []);
useEffect(() => {
const init = async () => {
setIsLoading(true);
await loadDefinitions();
setIsLoading(false);
};
init();
}, [loadDefinitions]);
const handleNewDefinition = async () => {
try {
const def = await window.electronAPI?.importDefinitions.create();
if (def) {
setDefinitions(prev => [def, ...prev]);
openTab({ type: 'import', id: def.id, isTransient: false });
}
} catch (error) {
console.error('Failed to create import definition:', error);
showToast.error('Failed to create import definition');
}
};
const handleOpenDefinition = (definitionId: string) => {
openTab({ type: 'import', id: definitionId, isTransient: false });
};
const handleDeleteDefinition = async (e: React.MouseEvent, definitionId: string) => {
e.stopPropagation();
try {
await window.electronAPI?.importDefinitions.delete(definitionId);
setDefinitions(prev => prev.filter(d => d.id !== definitionId));
closeTab(definitionId);
} catch (error) {
console.error('Failed to delete import definition:', error);
showToast.error('Failed to delete import definition');
}
};
const formatDate = (dateString: string) => {
const date = new Date(dateString);
const now = new Date();
const diffDays = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24));
if (diffDays === 0) {
return date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
} else if (diffDays === 1) {
return 'Yesterday';
} else if (diffDays < 7) {
return date.toLocaleDateString('en-US', { weekday: 'short' });
}
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
};
if (isLoading) {
return (
<div className="chat-list">
<div className="chat-list-header">
<span>IMPORTS</span>
</div>
<div className="chat-loading">Loading...</div>
</div>
);
}
return (
<div className="chat-list">
<div className="chat-list-header">
<span>IMPORTS</span>
<button className="chat-new-button" onClick={handleNewDefinition} title="New Import Definition">
+
</button>
</div>
<div className="chat-list-items">
{definitions.length === 0 ? (
<div className="chat-empty">
<p>No import definitions yet</p>
<button className="chat-start-button" onClick={handleNewDefinition}>
Create an import definition
</button>
</div>
) : (
definitions.map(def => (
<div
key={def.id}
className="chat-list-item"
onClick={() => handleOpenDefinition(def.id)}
>
<div className="chat-item-content">
<div className="chat-item-title">{def.name}</div>
<div className="chat-item-date">{formatDate(def.updatedAt)}</div>
</div>
<button
className="chat-item-delete"
onClick={(e) => handleDeleteDefinition(e, def.id)}
title="Delete import definition"
>
×
</button>
</div>
))
)}
</div>
</div>
);
};
export const Sidebar: React.FC = () => {
const { activeView, sidebarVisible } = useAppStore();
@@ -1282,6 +1403,7 @@ export const Sidebar: React.FC = () => {
{activeView === 'settings' && <SettingsNav />}
{activeView === 'tags' && <TagsNav />}
{activeView === 'chat' && <ChatList />}
{activeView === 'import' && <ImportList />}
</div>
);
};

View File

@@ -8,7 +8,8 @@ const getTabTitle = (
tab: Tab,
posts: { id: string; title: string }[],
media: { id: string; originalName: string }[],
chatTitles: Map<string, string>
chatTitles: Map<string, string>,
importDefTitles: Map<string, string>
): string => {
if (tab.type === 'settings') {
return 'Settings';
@@ -40,7 +41,7 @@ const getTabTitle = (
}
if (tab.type === 'import') {
return 'Import Analysis';
return importDefTitles.get(tab.id) || 'Import';
}
return 'Unknown';
@@ -129,6 +130,7 @@ export const TabBar: React.FC = () => {
const [showLeftArrow, setShowLeftArrow] = useState(false);
const [showRightArrow, setShowRightArrow] = useState(false);
const [chatTitles, setChatTitles] = useState<Map<string, string>>(new Map());
const [importDefTitles, setImportDefTitles] = useState<Map<string, string>>(new Map());
// Fetch chat titles for chat tabs
useEffect(() => {
@@ -175,6 +177,33 @@ export const TabBar: React.FC = () => {
};
}, []);
// Fetch import definition titles for import tabs
useEffect(() => {
const importTabs = tabs.filter(t => t.type === 'import');
if (importTabs.length === 0) return;
const fetchTitles = async () => {
const newTitles = new Map(importDefTitles);
for (const tab of importTabs) {
if (!importDefTitles.has(tab.id)) {
try {
const def = await window.electronAPI?.importDefinitions.get(tab.id);
if (def) {
newTitles.set(tab.id, def.name);
}
} catch (error) {
console.error('Failed to fetch import definition title:', error);
}
}
}
if (newTitles.size !== importDefTitles.size) {
setImportDefTitles(newTitles);
}
};
fetchTitles();
}, [tabs]); // Note: intentionally not including importDefTitles to avoid infinite loops
// Check if arrows are needed based on scroll position
const updateArrowVisibility = useCallback(() => {
const container = tabsContainerRef.current;
@@ -305,7 +334,7 @@ export const TabBar: React.FC = () => {
{tabs.map((tab) => {
const isActive = tab.id === activeTabId;
const isDirty = tab.type === 'post' && dirtyPosts.has(tab.id);
const title = getTabTitle(tab, posts, media, chatTitles);
const title = getTabTitle(tab, posts, media, chatTitles, importDefTitles);
const icon = getTabIcon(tab);
return (

View File

@@ -127,10 +127,6 @@ interface AppState {
isLoading: boolean;
error: string | null;
// Import Analysis
importAnalysis: unknown | null;
importAnalysisLoading: boolean;
// Project Actions
setProjects: (projects: ProjectData[]) => void;
setActiveProject: (project: ProjectData | null) => void;
@@ -188,10 +184,6 @@ interface AppState {
setLoading: (loading: boolean) => void;
setError: (error: string | null) => void;
// Import Analysis Actions
setImportAnalysis: (report: unknown | null) => void;
setImportAnalysisLoading: (loading: boolean) => void;
}
export const useAppStore = create<AppState>()(
@@ -240,10 +232,6 @@ export const useAppStore = create<AppState>()(
isLoading: false,
error: null,
// Import Analysis State
importAnalysis: null,
importAnalysisLoading: false,
// Project Actions
setProjects: (projects) => set({ projects }),
setActiveProject: (activeProject) => set({ activeProject }),
@@ -417,10 +405,6 @@ export const useAppStore = create<AppState>()(
// Loading Actions
setLoading: (isLoading) => set({ isLoading }),
setError: (error) => set({ error }),
// Import Analysis Actions
setImportAnalysis: (importAnalysis) => set({ importAnalysis }),
setImportAnalysisLoading: (importAnalysisLoading) => set({ importAnalysisLoading }),
}),
{
name: STORAGE_KEY,

View File

@@ -1,5 +1,16 @@
// Type definitions for the Electron API exposed via preload
export interface ImportDefinitionData {
id: string;
projectId: string;
name: string;
wxrFilePath: string | null;
uploadsFolderPath: string | null;
lastAnalysisResult: unknown | null;
createdAt: string;
updatedAt: string;
}
export interface ProjectMetadata {
name: string;
description?: string;
@@ -386,6 +397,13 @@ export interface ElectronAPI {
analyzeFile: (filePath: string, uploadsFolder?: string) => Promise<unknown>;
selectUploadsFolder: () => Promise<string | null>;
};
importDefinitions: {
create: (name?: string) => Promise<ImportDefinitionData>;
get: (id: string) => Promise<ImportDefinitionData | null>;
getAll: () => Promise<ImportDefinitionData[]>;
update: (id: string, updates: Partial<Pick<ImportDefinitionData, 'name' | 'wxrFilePath' | 'uploadsFolderPath' | 'lastAnalysisResult'>>) => Promise<ImportDefinitionData | null>;
delete: (id: string) => Promise<boolean>;
};
chat: {
// API Key Management
checkReady: () => Promise<ChatReadyStatus>;