feat: git log as panel in the panel

This commit is contained in:
2026-02-17 13:13:55 +01:00
parent 5c0dbaff71
commit b13eba025a
9 changed files with 487 additions and 39 deletions

View File

@@ -22,6 +22,8 @@
}
.panel-tab {
background: transparent;
border: none;
padding: 6px 12px;
font-size: 12px;
color: var(--vscode-tab-inactiveForeground);
@@ -29,6 +31,11 @@
border-bottom: 2px solid transparent;
}
.panel-tab[aria-disabled='true'] {
opacity: 0.5;
cursor: not-allowed;
}
.panel-tab:hover {
color: var(--vscode-tab-activeForeground);
}
@@ -150,6 +157,43 @@
background-color: var(--vscode-button-secondaryBackground);
}
.git-log-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.git-log-target {
font-size: 12px;
color: var(--vscode-descriptionForeground);
padding: 0 2px 4px;
border-bottom: 1px solid var(--vscode-panel-border);
}
.git-log-item {
background-color: var(--vscode-sideBar-background);
border-radius: 4px;
padding: 8px;
}
.git-log-subject {
font-size: 12px;
color: var(--vscode-editor-foreground);
margin-bottom: 4px;
}
.git-log-meta {
display: flex;
flex-wrap: wrap;
gap: 10px;
font-size: 11px;
color: var(--vscode-descriptionForeground);
}
.git-log-hash {
font-family: var(--vscode-editor-font-family);
}
@keyframes spin {
to { transform: rotate(360deg); }
}

View File

@@ -1,23 +1,187 @@
import React from 'react';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useAppStore } from '../../store';
import './Panel.css';
type PanelTab = 'tasks' | 'output' | 'git-log';
function getPostRelativePath(createdAt: string, slug: string): string | null {
const createdDate = new Date(createdAt);
if (Number.isNaN(createdDate.getTime())) {
return null;
}
const year = String(createdDate.getFullYear());
const month = String(createdDate.getMonth() + 1).padStart(2, '0');
return `posts/${year}/${month}/${slug}.md`;
}
function normalizePath(value: string): string {
return value.replace(/\\/g, '/').replace(/\/+$/, '');
}
function toRelativePath(absolutePath: string, projectPath: string): string {
const normalizedAbsolute = normalizePath(absolutePath);
const normalizedProject = normalizePath(projectPath);
if (normalizedAbsolute.toLowerCase() === normalizedProject.toLowerCase()) {
return '';
}
const prefix = `${normalizedProject}/`;
if (normalizedAbsolute.toLowerCase().startsWith(prefix.toLowerCase())) {
return normalizedAbsolute.slice(prefix.length);
}
return normalizedAbsolute;
}
export const Panel: React.FC = () => {
const { panelVisible, tasks } = useAppStore();
const { panelVisible, tasks, tabs, activeTabId, posts, media, activeProject } = useAppStore();
const [activePanelTab, setActivePanelTab] = useState<PanelTab>('tasks');
const [gitLogLoading, setGitLogLoading] = useState(false);
const [gitLogError, setGitLogError] = useState<string | null>(null);
const [gitLogTargetLabel, setGitLogTargetLabel] = useState<string | null>(null);
const [gitLogEntries, setGitLogEntries] = useState<Array<{
hash: string;
shortHash: string;
date: string;
subject: string;
author: string;
}>>([]);
const requestIdRef = useRef(0);
const recentTasks = tasks.slice(-10).reverse();
const activeEditorTab = useMemo(() => tabs.find((tab) => tab.id === activeTabId) ?? null, [tabs, activeTabId]);
const canActivateGitLog = activeEditorTab?.type === 'post' || activeEditorTab?.type === 'media';
useEffect(() => {
if (!canActivateGitLog && activePanelTab === 'git-log') {
setActivePanelTab('tasks');
}
}, [canActivateGitLog, activePanelTab]);
useEffect(() => {
const projectPath = activeProject?.dataPath;
if (!projectPath || !activeEditorTab || (activeEditorTab.type !== 'post' && activeEditorTab.type !== 'media')) {
setGitLogEntries([]);
setGitLogTargetLabel(null);
setGitLogError(null);
setGitLogLoading(false);
return;
}
const currentRequestId = ++requestIdRef.current;
const loadFileHistory = async () => {
setGitLogLoading(true);
setGitLogError(null);
try {
let targetLabel = '';
let relativeFilePath = '';
if (activeEditorTab.type === 'post') {
const post = posts.find((item) => item.id === activeEditorTab.id) || await window.electronAPI?.posts.get(activeEditorTab.id);
if (!post) {
setGitLogEntries([]);
setGitLogTargetLabel(null);
setGitLogLoading(false);
return;
}
targetLabel = post.title || post.slug;
relativeFilePath = getPostRelativePath(post.createdAt, post.slug) || '';
} else {
const mediaItem = media.find((item) => item.id === activeEditorTab.id) || await window.electronAPI?.media.get(activeEditorTab.id);
if (!mediaItem) {
setGitLogEntries([]);
setGitLogTargetLabel(null);
setGitLogLoading(false);
return;
}
targetLabel = mediaItem.title || mediaItem.originalName;
const absoluteMediaPath = await window.electronAPI?.media.getFilePath(activeEditorTab.id);
if (!absoluteMediaPath) {
setGitLogEntries([]);
setGitLogTargetLabel(targetLabel);
setGitLogLoading(false);
return;
}
relativeFilePath = toRelativePath(absoluteMediaPath, projectPath);
}
if (!relativeFilePath) {
setGitLogEntries([]);
setGitLogTargetLabel(targetLabel || null);
setGitLogLoading(false);
return;
}
const entries = await window.electronAPI?.git.getFileHistory(projectPath, relativeFilePath, 50);
if (requestIdRef.current !== currentRequestId) {
return;
}
setGitLogEntries(entries || []);
setGitLogTargetLabel(targetLabel || relativeFilePath);
} catch (error) {
if (requestIdRef.current !== currentRequestId) {
return;
}
setGitLogError(error instanceof Error ? error.message : 'Failed to load git log.');
setGitLogEntries([]);
} finally {
if (requestIdRef.current === currentRequestId) {
setGitLogLoading(false);
}
}
};
void loadFileHistory();
}, [activeEditorTab, activeProject?.dataPath, posts, media]);
if (!panelVisible) {
return null;
}
const recentTasks = tasks.slice(-10).reverse();
return (
<div className="panel">
<div className="panel-header">
<div className="panel-tabs">
<div className="panel-tab active">Tasks</div>
<div className="panel-tab">Output</div>
<div className="panel-tab">Sync Log</div>
<div className="panel-tabs" role="tablist" aria-label="Panel tabs">
<button
type="button"
role="tab"
className={`panel-tab ${activePanelTab === 'tasks' ? 'active' : ''}`}
aria-selected={activePanelTab === 'tasks'}
onClick={() => setActivePanelTab('tasks')}
>
Tasks
</button>
<button
type="button"
role="tab"
className={`panel-tab ${activePanelTab === 'output' ? 'active' : ''}`}
aria-selected={activePanelTab === 'output'}
onClick={() => setActivePanelTab('output')}
>
Output
</button>
<button
type="button"
role="tab"
className={`panel-tab ${activePanelTab === 'git-log' ? 'active' : ''}`}
aria-selected={activePanelTab === 'git-log'}
aria-disabled={!canActivateGitLog}
onClick={() => {
if (canActivateGitLog) {
setActivePanelTab('git-log');
}
}}
>
Git Log
</button>
</div>
<button
className="panel-close"
@@ -28,40 +192,72 @@ export const Panel: React.FC = () => {
</button>
</div>
<div className="panel-content">
{recentTasks.length === 0 ? (
<div className="panel-empty">No recent tasks</div>
) : (
<div className="task-list">
{recentTasks.map(task => (
<div key={task.taskId} className={`task-item status-${task.status}`}>
<div className="task-status">
{task.status === 'running' && <span className="task-spinner" />}
{task.status === 'completed' && <span className="task-check"></span>}
{task.status === 'failed' && <span className="task-error"></span>}
{task.status === 'pending' && <span className="task-pending"></span>}
</div>
<div className="task-info">
<div className="task-message">{task.message}</div>
{activePanelTab === 'tasks' && (
recentTasks.length === 0 ? (
<div className="panel-empty">No recent tasks</div>
) : (
<div className="task-list">
{recentTasks.map(task => (
<div key={task.taskId} className={`task-item status-${task.status}`}>
<div className="task-status">
{task.status === 'running' && <span className="task-spinner" />}
{task.status === 'completed' && <span className="task-check"></span>}
{task.status === 'failed' && <span className="task-error"></span>}
{task.status === 'pending' && <span className="task-pending"></span>}
</div>
<div className="task-info">
<div className="task-message">{task.message}</div>
{task.status === 'running' && (
<div className="task-progress-bar">
<div
className="task-progress-fill"
style={{ width: `${task.progress}%` }}
/>
</div>
)}
</div>
{task.status === 'running' && (
<div className="task-progress-bar">
<div
className="task-progress-fill"
style={{ width: `${task.progress}%` }}
/>
</div>
<button
className="task-cancel"
onClick={() => window.electronAPI?.tasks.cancel(task.taskId)}
>
Cancel
</button>
)}
</div>
{task.status === 'running' && (
<button
className="task-cancel"
onClick={() => window.electronAPI?.tasks.cancel(task.taskId)}
>
Cancel
</button>
)}
</div>
))}
</div>
))}
</div>
)
)}
{activePanelTab === 'output' && (
<div className="panel-empty">No output</div>
)}
{activePanelTab === 'git-log' && (
!canActivateGitLog ? (
<div className="panel-empty">Open a post or media editor to view git log</div>
) : gitLogLoading ? (
<div className="panel-empty">Loading git log...</div>
) : gitLogError ? (
<div className="panel-empty">{gitLogError}</div>
) : gitLogEntries.length === 0 ? (
<div className="panel-empty">No commits found for this item</div>
) : (
<div className="git-log-list">
<div className="git-log-target">{gitLogTargetLabel}</div>
{gitLogEntries.map((entry) => (
<div key={entry.hash} className="git-log-item">
<div className="git-log-subject">{entry.subject}</div>
<div className="git-log-meta">
<span className="git-log-hash">{entry.shortHash}</span>
<span>{entry.author}</span>
<span>{new Date(entry.date).toLocaleString()}</span>
</div>
</div>
))}
</div>
)
)}
</div>
</div>