feat: Enhance navigation and cleanup for graph explorer view

- Added a cleanup function to reset the state when navigating away from the graph explorer.
- Updated navigation logic to call the cleanup function before switching views.
- Improved internationalization by adding new translations for graph-related terms.
- Adjusted icon sizes for better UI consistency in the graph explorer.
- Implemented impact analysis button functionality in the graph explorer.
- Refactored CLI tool configuration to use updated model names.
- Enhanced CLI executor to handle prompts correctly for codex commands.
- Introduced code relationship storage for better visualization in the index tree.
- Added support for parsing Markdown and plain text files in the symbol parser.
- Updated tests to reflect changes in language detection logic.
This commit is contained in:
catlog22
2025-12-15 23:11:01 +08:00
parent 894b93e08d
commit 35485bbbb1
35 changed files with 3348 additions and 228 deletions

View File

@@ -2098,6 +2098,7 @@
top: 0;
right: 0;
width: 50vw;
min-width: 600px;
max-width: 100vw;
height: 100vh;
background: hsl(var(--card));

View File

@@ -34,13 +34,7 @@
flex-shrink: 0;
}
.graph-explorer-header-left {
display: flex;
align-items: center;
gap: 1rem;
}
.graph-explorer-header-left h2 {
.graph-explorer-header h2 {
display: flex;
align-items: center;
gap: 0.5rem;
@@ -50,6 +44,12 @@
margin: 0;
}
.graph-explorer-header-left {
display: flex;
align-items: center;
gap: 1rem;
}
.graph-explorer-header-right {
display: flex;
align-items: center;
@@ -190,27 +190,7 @@
user-select: none;
}
/* ========================================
* Graph Main Content
* ======================================== */
.graph-main {
display: flex;
flex: 1;
gap: 0;
min-height: 0;
overflow: hidden;
}
/* Cytoscape Graph Canvas */
.cytoscape-container {
flex: 1;
position: relative;
background: hsl(var(--card));
border: 1px solid hsl(var(--border));
border-radius: 0.75rem;
overflow: hidden;
min-height: 400px;
}
/* Note: .graph-main and .cytoscape-container styles are defined in the Additional Classes section below */
#cy {
width: 100%;
@@ -1134,3 +1114,444 @@
font-size: 0.875rem;
margin: 0;
}
/* ========================================
* Additional Classes for JS Compatibility
* ======================================== */
/* Explorer Tabs */
.graph-explorer-tabs {
display: flex;
align-items: center;
gap: 0.5rem;
}
.tab-btn {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 1rem;
font-size: 0.875rem;
font-weight: 500;
color: hsl(var(--muted-foreground));
background: transparent;
border: 1px solid hsl(var(--border));
border-radius: 0.375rem;
cursor: pointer;
transition: all 0.15s ease;
}
.tab-btn:hover {
background: hsl(var(--hover));
color: hsl(var(--foreground));
}
.tab-btn.active {
background: hsl(var(--primary));
color: hsl(var(--primary-foreground));
border-color: hsl(var(--primary));
}
/* Tab Content */
.graph-explorer-content {
display: flex;
flex: 1;
min-height: 0;
overflow: hidden;
}
.tab-content {
display: none;
width: 100%;
height: 100%;
}
.tab-content.active {
display: flex;
}
/* Graph View Layout */
.graph-view {
display: flex;
width: 100%;
height: 100%;
gap: 1rem;
padding: 0;
}
.graph-sidebar {
width: 240px;
min-width: 240px;
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 0;
overflow-y: auto;
padding: 0.5rem 1rem 0.5rem 0;
border-right: 1px solid hsl(var(--border));
margin-right: 1rem;
}
.graph-main {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
height: 100%;
}
.graph-controls-section,
.graph-legend-section {
padding: 0;
}
.graph-controls-section h3,
.graph-legend-section h3 {
font-size: 0.75rem;
font-weight: 600;
color: hsl(var(--muted-foreground));
margin: 0 0 0.75rem 0;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.graph-legend-section {
border-top: 1px solid hsl(var(--border));
padding-top: 1rem;
margin-top: 0.5rem;
}
/* Graph Legend */
.graph-legend {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.legend-title {
font-size: 0.75rem;
font-weight: 600;
color: hsl(var(--muted-foreground));
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.25rem;
}
.legend-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.legend-line {
width: 20px;
height: 3px;
border-radius: 2px;
flex-shrink: 0;
}
.filter-color {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
/* Graph Toolbar */
.graph-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 0.75rem;
background: hsl(var(--card));
border: 1px solid hsl(var(--border));
border-radius: 0.5rem 0.5rem 0 0;
border-bottom: none;
}
.graph-toolbar-left,
.graph-toolbar-right {
display: flex;
align-items: center;
gap: 0.75rem;
}
.graph-stats {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.8125rem;
color: hsl(var(--muted-foreground));
}
.btn-icon {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
background: transparent;
border: 1px solid hsl(var(--border));
border-radius: 0.375rem;
color: hsl(var(--muted-foreground));
cursor: pointer;
transition: all 0.15s ease;
}
.btn-icon:hover {
background: hsl(var(--hover));
color: hsl(var(--foreground));
border-color: hsl(var(--primary) / 0.3);
}
/* Filter Checkboxes */
.filter-dropdowns {
display: flex;
flex-direction: column;
gap: 1rem;
}
.filter-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.filter-group > label {
font-size: 0.75rem;
font-weight: 600;
color: hsl(var(--muted-foreground));
text-transform: uppercase;
letter-spacing: 0.05em;
}
.filter-checkbox {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.8125rem;
color: hsl(var(--foreground));
cursor: pointer;
}
.filter-checkbox input[type="checkbox"] {
width: 14px;
height: 14px;
cursor: pointer;
accent-color: hsl(var(--primary));
}
/* Legend Items */
.legend-section {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.legend-section-title {
font-size: 0.75rem;
font-weight: 600;
color: hsl(var(--muted-foreground));
margin-bottom: 0.25rem;
}
.legend-item {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.8125rem;
color: hsl(var(--foreground));
}
.legend-color {
width: 12px;
height: 12px;
border-radius: 50%;
flex-shrink: 0;
}
/* Search Process View */
.search-process-view {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
gap: 1rem;
padding: 1rem;
overflow-y: auto;
}
.search-process-header {
text-align: center;
padding: 1rem;
}
.search-process-header h3 {
font-size: 1rem;
font-weight: 600;
color: hsl(var(--foreground));
margin: 0 0 0.5rem 0;
}
.search-process-header p {
font-size: 0.875rem;
color: hsl(var(--muted-foreground));
margin: 0;
}
.search-empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1;
text-align: center;
color: hsl(var(--muted-foreground));
}
.search-empty-state i {
margin-bottom: 1rem;
opacity: 0.5;
}
/* Search Process Empty State */
.search-process-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
text-align: center;
color: hsl(var(--muted-foreground));
padding: 2rem;
}
.search-process-empty i {
margin-bottom: 1rem;
opacity: 0.5;
}
.search-process-empty p {
font-size: 0.875rem;
margin: 0;
}
/* Search Process Timeline */
.search-process-timeline {
display: flex;
flex-direction: column;
gap: 1rem;
}
.search-step {
display: flex;
gap: 1rem;
padding: 1rem;
background: hsl(var(--card));
border: 1px solid hsl(var(--border));
border-radius: 0.5rem;
}
.search-step-number {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
background: hsl(var(--primary));
color: hsl(var(--primary-foreground));
border-radius: 50%;
font-weight: 600;
font-size: 0.875rem;
flex-shrink: 0;
}
.search-step-content {
flex: 1;
}
.search-step-content h4 {
font-size: 0.875rem;
font-weight: 600;
color: hsl(var(--foreground));
margin: 0 0 0.25rem 0;
}
.search-step-content p {
font-size: 0.8125rem;
color: hsl(var(--muted-foreground));
margin: 0;
}
.search-step-results {
margin-top: 0.5rem;
}
.result-count {
font-size: 0.75rem;
color: hsl(var(--primary));
font-weight: 500;
}
/* Cytoscape Container */
.cytoscape-container {
flex: 1;
min-height: 400px;
background: hsl(var(--card));
border: 1px solid hsl(var(--border));
border-radius: 0.5rem;
position: relative;
}
/* Cytoscape Empty State */
.cytoscape-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
min-height: 400px;
text-align: center;
color: hsl(var(--muted-foreground));
}
.cytoscape-empty i {
margin-bottom: 1rem;
opacity: 0.5;
}
.cytoscape-empty p {
font-size: 0.875rem;
margin: 0;
}
/* Graph Empty State */
.graph-empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
text-align: center;
color: hsl(var(--muted-foreground));
}
.graph-empty-state i {
margin-bottom: 1rem;
opacity: 0.5;
}
.graph-empty-state p {
font-size: 0.875rem;
margin: 0;
}
/* Hidden class */
.hidden {
display: none !important;
}

View File

@@ -52,10 +52,25 @@ function initPathSelector() {
});
}
// Cleanup function for view transitions
function cleanupPreviousView() {
// Cleanup graph explorer
if (currentView === 'graph-explorer' && typeof window.cleanupGraphExplorer === 'function') {
window.cleanupGraphExplorer();
}
// Hide storage card when leaving cli-manager
var storageCard = document.getElementById('storageCard');
if (storageCard) {
storageCard.style.display = 'none';
}
}
// Navigation
function initNavigation() {
document.querySelectorAll('.nav-item[data-filter]').forEach(item => {
item.addEventListener('click', () => {
cleanupPreviousView();
setActiveNavItem(item);
currentFilter = item.dataset.filter;
currentLiteType = null;
@@ -70,6 +85,8 @@ function initNavigation() {
// Lite Tasks Navigation
document.querySelectorAll('.nav-item[data-lite]').forEach(item => {
item.addEventListener('click', () => {
cleanupPreviousView();
setActiveNavItem(item);
currentLiteType = item.dataset.lite;
currentFilter = null;
@@ -84,6 +101,8 @@ function initNavigation() {
// View Navigation (Project Overview, MCP Manager, etc.)
document.querySelectorAll('.nav-item[data-view]').forEach(item => {
item.addEventListener('click', () => {
cleanupPreviousView();
setActiveNavItem(item);
currentView = item.dataset.view;
currentFilter = null;

View File

@@ -59,40 +59,22 @@ function renderStorageCard() {
return date.toLocaleDateString();
};
// Build project rows
// Build project tree (hierarchical view)
let projectRows = '';
if (projects && projects.length > 0) {
projects.slice(0, 5).forEach(p => {
const historyBadge = p.historyRecords > 0
? '<span class="text-xs px-1.5 py-0.5 bg-primary/10 text-primary rounded">' + p.historyRecords + '</span>'
: '<span class="text-xs text-muted-foreground">-</span>';
const tree = buildProjectTree(projects);
projectRows = renderProjectTree(tree, 0, formatTimeAgo);
projectRows += '\
<tr class="border-b border-border/50 hover:bg-muted/30">\
<td class="py-2 px-2 font-mono text-xs text-muted-foreground">' + escapeHtml(p.id.substring(0, 8)) + '...</td>\
<td class="py-2 px-2 text-sm text-right">' + escapeHtml(p.totalSizeFormatted) + '</td>\
<td class="py-2 px-2 text-center">' + historyBadge + '</td>\
<td class="py-2 px-2 text-xs text-muted-foreground text-right">' + formatTimeAgo(p.lastModified) + '</td>\
<td class="py-2 px-1 text-right">\
<button onclick="cleanProjectStorage(\'' + escapeHtml(p.id) + '\')" \
class="text-xs px-2 py-1 text-destructive hover:bg-destructive/10 rounded transition-colors" \
title="Clean this project storage">\
<i data-lucide="trash-2" class="w-3 h-3"></i>\
</button>\
</td>\
</tr>\
';
});
if (projects.length > 5) {
projectRows += '\
<tr>\
<td colspan="5" class="py-2 px-2 text-xs text-muted-foreground text-center">\
... and ' + (projects.length - 5) + ' more projects\
</td>\
</tr>\
';
}
// Initially hide all child rows (level > 0)
setTimeout(() => {
const allRows = document.querySelectorAll('.project-row');
allRows.forEach(row => {
const level = parseInt(row.getAttribute('data-level'));
if (level > 0) {
row.style.display = 'none';
}
});
}, 0);
} else {
projectRows = '\
<tr>\
@@ -178,6 +160,162 @@ function getTotalRecords() {
return storageData.projects.reduce((sum, p) => sum + (p.historyRecords || 0), 0);
}
/**
* Build project tree from flat list
* Converts flat project list to hierarchical tree structure
*/
function buildProjectTree(projects) {
const tree = [];
const map = new Map();
// Sort by path depth (shallowest first)
const sorted = projects.slice().sort((a, b) => {
const depthA = (a.id.match(/\//g) || []).length;
const depthB = (b.id.match(/\//g) || []).length;
return depthA - depthB;
});
for (const project of sorted) {
const segments = project.id.split('/');
if (segments.length === 1) {
// Root level project
const node = {
...project,
children: [],
isExpanded: false
};
tree.push(node);
map.set(project.id, node);
} else {
// Sub-project
const parentId = segments.slice(0, -1).join('/');
const parent = map.get(parentId);
if (parent) {
const node = {
...project,
children: [],
isExpanded: false
};
parent.children.push(node);
map.set(project.id, node);
} else {
// Orphaned project (parent not found) - add to root
const node = {
...project,
children: [],
isExpanded: false
};
tree.push(node);
map.set(project.id, node);
}
}
}
return tree;
}
/**
* Render project tree recursively
*/
function renderProjectTree(tree, level = 0, formatTimeAgo) {
if (!tree || tree.length === 0) return '';
let html = '';
for (const node of tree) {
const hasChildren = node.children && node.children.length > 0;
const indent = level * 20;
const projectName = node.id.split('/').pop();
const historyBadge = node.historyRecords > 0
? '<span class="text-xs px-1.5 py-0.5 bg-primary/10 text-primary rounded">' + node.historyRecords + '</span>'
: '<span class="text-xs text-muted-foreground">-</span>';
const toggleIcon = hasChildren
? '<i data-lucide="chevron-right" class="w-3 h-3 transition-transform duration-200 toggle-icon"></i>'
: '<span class="w-3 h-3 inline-block"></span>';
html += '\
<tr class="border-b border-border/50 hover:bg-muted/30 project-row" data-project-id="' + escapeHtml(node.id) + '" data-level="' + level + '">\
<td class="py-2 px-2 font-mono text-xs text-muted-foreground">\
<div class="flex items-center gap-1" style="padding-left: ' + indent + 'px">\
' + (hasChildren ? '<button class="toggle-btn hover:bg-muted/50 rounded p-0.5" onclick="toggleProjectNode(\'' + escapeHtml(node.id) + '\')">' + toggleIcon + '</button>' : '<span class="w-3 h-3 inline-block"></span>') + '\
<span class="truncate max-w-[150px]" title="' + escapeHtml(node.id) + '">' + escapeHtml(projectName) + '</span>\
</div>\
</td>\
<td class="py-2 px-2 text-sm text-right">' + escapeHtml(node.totalSizeFormatted) + '</td>\
<td class="py-2 px-2 text-center">' + historyBadge + '</td>\
<td class="py-2 px-2 text-xs text-muted-foreground text-right">' + formatTimeAgo(node.lastModified) + '</td>\
<td class="py-2 px-1 text-right">\
<button onclick="cleanProjectStorage(\'' + escapeHtml(node.id) + '\')" \
class="text-xs px-2 py-1 text-destructive hover:bg-destructive/10 rounded transition-colors" \
title="Clean this project storage">\
<i data-lucide="trash-2" class="w-3 h-3"></i>\
</button>\
</td>\
</tr>\
';
// Render children (initially hidden)
if (hasChildren) {
html += renderProjectTree(node.children, level + 1, formatTimeAgo);
}
}
return html;
}
/**
* Toggle project node expansion
*/
function toggleProjectNode(projectId) {
const row = document.querySelector('[data-project-id="' + projectId + '"]');
if (!row) return;
const icon = row.querySelector('.toggle-icon');
const level = parseInt(row.getAttribute('data-level'));
// Find all child rows
let nextRow = row.nextElementSibling;
const childRows = [];
while (nextRow && nextRow.classList.contains('project-row')) {
const nextLevel = parseInt(nextRow.getAttribute('data-level'));
if (nextLevel <= level) break;
childRows.push(nextRow);
nextRow = nextRow.nextElementSibling;
}
// Toggle visibility
const isExpanded = row.classList.contains('expanded');
if (isExpanded) {
// Collapse
row.classList.remove('expanded');
if (icon) icon.style.transform = 'rotate(0deg)';
childRows.forEach(child => {
child.style.display = 'none';
});
} else {
// Expand (only immediate children)
row.classList.add('expanded');
if (icon) icon.style.transform = 'rotate(90deg)';
childRows.forEach(child => {
const childLevel = parseInt(child.getAttribute('data-level'));
if (childLevel === level + 1) {
child.style.display = '';
}
});
}
// Reinitialize Lucide icons
if (typeof lucide !== 'undefined') {
lucide.createIcons();
}
}
/**
* Render error state for storage card
*/

View File

@@ -506,6 +506,27 @@ const i18n = {
'mcp.codex.enabledTools': 'Tools',
'mcp.codex.tools': 'tools enabled',
// Claude to Codex copy
'mcp.claude.copyFromCodex': 'Copy Codex Servers to Claude',
'mcp.claude.alreadyAdded': 'Already in Claude',
'mcp.claude.copyToClaude': 'Copy to Claude Global',
// MCP Edit Modal
'mcp.editModal.title': 'Edit MCP Server',
'mcp.editModal.serverNamePlaceholder': 'server-name',
'mcp.editModal.onePerLine': 'one per line',
'mcp.editModal.save': 'Save Changes',
'mcp.editModal.delete': 'Delete',
'mcp.editModal.nameRequired': 'Server name is required',
'mcp.editModal.commandRequired': 'Command or URL is required',
'mcp.editModal.saved': 'MCP server "{name}" updated',
'mcp.editModal.saveFailed': 'Failed to save MCP server',
'mcp.editModal.deleteConfirm': 'Delete MCP server "{name}"?',
'mcp.editModal.deleted': 'MCP server "{name}" deleted',
'mcp.editModal.deleteFailed': 'Failed to delete MCP server',
'mcp.clickToEdit': 'Click to edit',
'mcp.clickToViewDetails': 'Click to view details',
// Hook Manager
'hook.projectHooks': 'Project Hooks',
'hook.projectFile': '.claude/settings.json',
@@ -729,6 +750,7 @@ const i18n = {
'memory.contextHotspots': 'Context Hotspots',
'memory.mostRead': 'Most Read Files',
'memory.mostEdited': 'Most Edited Files',
'memory.mostMentioned': 'Most Mentioned Topics',
'memory.today': 'Today',
'memory.week': 'Week',
'memory.allTime': 'All Time',
@@ -958,6 +980,29 @@ const i18n = {
'graph.zoomIn': 'Zoom In',
'graph.zoomOut': 'Zoom Out',
'graph.resetLayout': 'Reset Layout',
'graph.title': 'Code Graph',
'graph.filters': 'Filters',
'graph.legend': 'Legend',
'graph.nodes': 'nodes',
'graph.edges': 'edges',
'graph.noGraphData': 'No graph data available. Index this project with codex-lens first.',
'graph.noSearchData': 'No search process data available.',
'graph.center': 'Center',
'graph.resetFilters': 'Reset Filters',
'graph.cytoscapeNotLoaded': 'Graph library not loaded',
'graph.impactAnalysisError': 'Failed to load impact analysis',
'graph.searchProcessDesc': 'Visualize how search queries flow through the system',
'graph.searchProcessTitle': 'Search Pipeline',
'graph.resultsFound': 'results found',
'graph.type': 'Type',
'graph.line': 'Line',
'graph.path': 'Path',
'graph.depth': 'Depth',
'graph.exports': 'Exports',
'graph.imports': 'Imports',
'graph.references': 'References',
'graph.symbolType': 'Symbol Type',
'graph.affectedSymbols': 'Affected Symbols',
// CLI Sync (used in claude-manager.js)
'claude.cliSync': 'CLI Auto-Sync',
@@ -1025,7 +1070,7 @@ const i18n = {
zh: {
// App title and brand
'app.title': 'CCW 控制面板',
'app.brand': 'Claude Code 工作流',
'app.brand': 'Claude Code Workflow',
// Header
'header.project': '项目:',
@@ -1498,6 +1543,27 @@ const i18n = {
'mcp.codex.enabledTools': '工具',
'mcp.codex.tools': '个工具已启用',
// Claude to Codex copy
'mcp.claude.copyFromCodex': '从 Codex 复制服务器到 Claude',
'mcp.claude.alreadyAdded': '已在 Claude 中',
'mcp.claude.copyToClaude': '复制到 Claude 全局',
// MCP Edit Modal
'mcp.editModal.title': '编辑 MCP 服务器',
'mcp.editModal.serverNamePlaceholder': 'server-name',
'mcp.editModal.onePerLine': '每行一个',
'mcp.editModal.save': '保存更改',
'mcp.editModal.delete': '删除',
'mcp.editModal.nameRequired': '服务器名称必填',
'mcp.editModal.commandRequired': '命令或 URL 必填',
'mcp.editModal.saved': 'MCP 服务器 "{name}" 已更新',
'mcp.editModal.saveFailed': '保存 MCP 服务器失败',
'mcp.editModal.deleteConfirm': '删除 MCP 服务器 "{name}"',
'mcp.editModal.deleted': 'MCP 服务器 "{name}" 已删除',
'mcp.editModal.deleteFailed': '删除 MCP 服务器失败',
'mcp.clickToEdit': '点击编辑',
'mcp.clickToViewDetails': '点击查看详情',
// Hook Manager
'hook.projectHooks': '项目钩子',
'hook.projectFile': '.claude/settings.json',
@@ -1721,6 +1787,7 @@ const i18n = {
'memory.contextHotspots': '上下文热点',
'memory.mostRead': '最常读取的文件',
'memory.mostEdited': '最常编辑的文件',
'memory.mostMentioned': '最常提及的话题',
'memory.today': '今天',
'memory.week': '本周',
'memory.allTime': '全部时间',
@@ -1950,6 +2017,29 @@ const i18n = {
'graph.zoomIn': '放大',
'graph.zoomOut': '缩小',
'graph.resetLayout': '重置布局',
'graph.title': '代码图谱',
'graph.filters': '筛选器',
'graph.legend': '图例',
'graph.nodes': '个节点',
'graph.edges': '条边',
'graph.noGraphData': '无图谱数据。请先使用 codex-lens 为此项目建立索引。',
'graph.noSearchData': '无搜索过程数据。',
'graph.center': '居中',
'graph.resetFilters': '重置筛选',
'graph.cytoscapeNotLoaded': '图谱库未加载',
'graph.impactAnalysisError': '加载影响分析失败',
'graph.searchProcessDesc': '可视化搜索查询在系统中的流转过程',
'graph.searchProcessTitle': '搜索管道',
'graph.resultsFound': '个结果',
'graph.type': '类型',
'graph.line': '行号',
'graph.path': '路径',
'graph.depth': '深度',
'graph.exports': '导出',
'graph.imports': '导入',
'graph.references': '引用',
'graph.symbolType': '符号类型',
'graph.affectedSymbols': '受影响符号',
// CLI Sync (used in claude-manager.js)
'claude.cliSync': 'CLI 自动同步',

View File

@@ -222,7 +222,7 @@ function renderGraphLegend() {
function renderSearchProcessView() {
if (!searchProcessData) {
return '<div class="search-process-empty">' +
'<i data-lucide="search-x" class="w-12 h-12"></i>' +
'<i data-lucide="search-x" class="w-8 h-8"></i>' +
'<p>' + t('graph.noSearchData') + '</p>' +
'</div>';
}
@@ -280,7 +280,7 @@ function initializeCytoscape() {
// Check if Cytoscape.js is loaded
if (typeof cytoscape === 'undefined') {
container.innerHTML = '<div class="cytoscape-error">' +
'<i data-lucide="alert-triangle" class="w-8 h-8"></i>' +
'<i data-lucide="alert-triangle" class="w-6 h-6"></i>' +
'<p>' + t('graph.cytoscapeNotLoaded') + '</p>' +
'</div>';
if (window.lucide) lucide.createIcons();
@@ -289,7 +289,7 @@ function initializeCytoscape() {
if (graphData.nodes.length === 0) {
container.innerHTML = '<div class="cytoscape-empty">' +
'<i data-lucide="network" class="w-12 h-12"></i>' +
'<i data-lucide="network" class="w-8 h-8"></i>' +
'<p>' + t('graph.noGraphData') + '</p>' +
'</div>';
if (window.lucide) lucide.createIcons();
@@ -493,6 +493,15 @@ function selectNode(nodeData) {
panel.classList.remove('hidden');
panel.innerHTML = renderNodeDetails(nodeData);
if (window.lucide) lucide.createIcons();
// Attach event listener for impact analysis button (prevents XSS)
var impactBtn = document.getElementById('impactAnalysisBtn');
if (impactBtn) {
impactBtn.addEventListener('click', function() {
var nodeId = this.getAttribute('data-node-id');
if (nodeId) showImpactAnalysis(nodeId);
});
}
}
}
@@ -559,7 +568,7 @@ function renderNodeDetails(node) {
'</div>' +
'</div>' +
'<div class="node-details-actions">' +
'<button class="btn btn-sm btn-primary" onclick="showImpactAnalysis(\'' + escapeHtml(node.id) + '\')">' +
'<button class="btn btn-sm btn-primary" id="impactAnalysisBtn" data-node-id="' + escapeHtml(node.id) + '">' +
'<i data-lucide="target" class="w-3 h-3"></i> ' + t('graph.impactAnalysis') +
'</button>' +
'</div>' +
@@ -629,7 +638,7 @@ function centerCytoscape() {
// ========== Impact Analysis ==========
async function showImpactAnalysis(symbolId) {
try {
var response = await fetch('/api/graph/impact/' + encodeURIComponent(symbolId));
var response = await fetch('/api/graph/impact?symbol=' + encodeURIComponent(symbolId));
if (!response.ok) throw new Error('Failed to fetch impact analysis');
var data = await response.json();
@@ -727,3 +736,22 @@ function hideStatsAndCarousel() {
if (statsGrid) statsGrid.style.display = 'none';
if (carousel) carousel.style.display = 'none';
}
// ========== Cleanup Function ==========
/**
* Clean up Cytoscape instance to prevent memory leaks
* Should be called when navigating away from the graph explorer view
*/
function cleanupGraphExplorer() {
if (cyInstance) {
cyInstance.destroy();
cyInstance = null;
}
selectedNode = null;
searchProcessData = null;
}
// Register cleanup on navigation (called by navigation.js before switching views)
if (typeof window !== 'undefined') {
window.cleanupGraphExplorer = cleanupGraphExplorer;
}

View File

@@ -139,8 +139,9 @@ async function renderMcpManager() {
<i data-lucide="bot" class="w-4 h-4 inline mr-1.5"></i>
Claude
</button>
<button class="cli-mode-btn px-4 py-2 text-sm font-medium rounded-md transition-all ${currentCliMode === 'codex' ? 'bg-orange-500 text-white shadow-sm' : 'text-muted-foreground hover:text-foreground'}"
onclick="setCliMode('codex')">
<button class="cli-mode-btn px-4 py-2 text-sm font-medium rounded-md transition-all ${currentCliMode === 'codex' ? 'shadow-sm' : 'text-muted-foreground hover:text-foreground'}"
onclick="setCliMode('codex')"
style="${currentCliMode === 'codex' ? 'background-color: #f97316; color: white;' : ''}">
<i data-lucide="code-2" class="w-4 h-4 inline mr-1.5"></i>
Codex
</button>
@@ -228,6 +229,7 @@ async function renderMcpManager() {
<div class="flex items-center gap-2">
<i data-lucide="bot" class="w-5 h-5 text-primary"></i>
<h4 class="font-semibold text-foreground">${escapeHtml(serverName)}</h4>
<span class="text-xs px-2 py-0.5 bg-primary/10 text-primary rounded-full">Claude</span>
${alreadyInCodex ? `<span class="text-xs px-2 py-0.5 bg-success/10 text-success rounded-full">${t('mcp.codex.alreadyAdded')}</span>` : ''}
</div>
${!alreadyInCodex ? `
@@ -250,6 +252,26 @@ async function renderMcpManager() {
</div>
</div>
` : ''}
<!-- Available MCP Servers from Other Projects (Codex mode) -->
<div class="mcp-section">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-foreground">${t('mcp.availableOther')}</h3>
<span class="text-sm text-muted-foreground">${otherProjectServers.length} ${t('mcp.serversAvailable')}</span>
</div>
${otherProjectServers.length === 0 ? `
<div class="mcp-empty-state bg-card border border-border rounded-lg p-6 text-center">
<p class="text-muted-foreground">${t('empty.noAdditionalMcp')}</p>
</div>
` : `
<div class="mcp-server-grid grid gap-3">
${otherProjectServers.map(([serverName, serverInfo]) => {
return renderAvailableServerCardForCodex(serverName, serverInfo);
}).join('')}
</div>
`}
</div>
` : `
<!-- CCW Tools MCP Server Card -->
<div class="mcp-section mb-6">
@@ -486,6 +508,55 @@ async function renderMcpManager() {
</div>
` : ''}
<!-- Copy Codex Servers to Claude (Claude mode only) -->
${currentCliMode === 'claude' && Object.keys(codexMcpServers || {}).length > 0 ? `
<div class="mcp-section mb-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-foreground flex items-center gap-2">
<i data-lucide="copy" class="w-5 h-5"></i>
${t('mcp.claude.copyFromCodex')}
</h3>
<span class="text-sm text-muted-foreground">${Object.keys(codexMcpServers || {}).length} ${t('mcp.serversAvailable')}</span>
</div>
<div class="mcp-server-grid grid gap-3">
${Object.entries(codexMcpServers || {}).map(([serverName, serverConfig]) => {
const alreadyInClaude = mcpUserServers && mcpUserServers[serverName];
const isStdio = !!serverConfig.command;
const isHttp = !!serverConfig.url;
return `
<div class="mcp-server-card bg-card border ${alreadyInClaude ? 'border-success/50' : 'border-orange-200 dark:border-orange-800'} border-dashed rounded-lg p-4 hover:shadow-md transition-all">
<div class="flex items-start justify-between mb-3">
<div class="flex items-center gap-2 flex-wrap">
<i data-lucide="code-2" class="w-5 h-5 text-orange-500"></i>
<h4 class="font-semibold text-foreground">${escapeHtml(serverName)}</h4>
<span class="text-xs px-2 py-0.5 bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-300 rounded-full">Codex</span>
${isHttp
? '<span class="text-xs px-2 py-0.5 bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300 rounded-full">HTTP</span>'
: '<span class="text-xs px-2 py-0.5 bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300 rounded-full">STDIO</span>'
}
${alreadyInClaude ? '<span class="text-xs px-2 py-0.5 bg-success/10 text-success rounded-full">' + t('mcp.claude.alreadyAdded') + '</span>' : ''}
</div>
${!alreadyInClaude ? `
<button class="px-3 py-1 text-xs bg-primary text-primary-foreground rounded hover:opacity-90 transition-opacity"
onclick="copyCodexServerToClaude('${escapeHtml(serverName)}', ${JSON.stringify(serverConfig).replace(/'/g, "&#39;")})"
title="${t('mcp.claude.copyToClaude')}">
<i data-lucide="arrow-right" class="w-3.5 h-3.5 inline"></i> Claude
</button>
` : ''}
</div>
<div class="mcp-server-details text-sm space-y-1">
<div class="flex items-center gap-2 text-muted-foreground">
<span class="font-mono text-xs bg-muted px-1.5 py-0.5 rounded">${isHttp ? t('mcp.url') : t('mcp.cmd')}</span>
<span class="truncate" title="${escapeHtml(serverConfig.command || serverConfig.url || 'N/A')}">${escapeHtml(serverConfig.command || serverConfig.url || 'N/A')}</span>
</div>
</div>
</div>
`;
}).join('')}
</div>
</div>
` : ''}
<!-- All Projects MCP Overview Table (Claude mode only) -->
${currentCliMode === 'claude' ? `
<div class="mcp-section mt-6">
@@ -676,7 +747,12 @@ function renderGlobalManagementCard(serverName, serverConfig) {
const serverType = serverConfig.type || 'stdio';
return `
<div class="mcp-server-card mcp-server-global bg-card border border-success/30 rounded-lg p-4 hover:shadow-md transition-all">
<div class="mcp-server-card mcp-server-global bg-card border border-success/30 rounded-lg p-4 hover:shadow-md transition-all cursor-pointer"
data-server-name="${escapeHtml(serverName)}"
data-server-config="${escapeHtml(JSON.stringify(serverConfig))}"
data-server-source="global"
data-action="view-details"
title="${t('mcp.clickToEdit')}">
<div class="flex items-start justify-between mb-3">
<div class="flex items-center gap-2">
<i data-lucide="globe" class="w-5 h-5 text-success"></i>
@@ -706,7 +782,7 @@ function renderGlobalManagementCard(serverName, serverConfig) {
</div>
</div>
<div class="mt-3 pt-3 border-t border-border flex items-center justify-end">
<div class="mt-3 pt-3 border-t border-border flex items-center justify-end" onclick="event.stopPropagation()">
<button class="text-xs text-destructive hover:text-destructive/80 transition-colors"
data-server-name="${escapeHtml(serverName)}"
data-action="remove-global">
@@ -807,6 +883,70 @@ function renderAvailableServerCard(serverName, serverInfo) {
`;
}
// Render available server card for Codex mode (with Claude badge and copy to Codex button)
function renderAvailableServerCardForCodex(serverName, serverInfo) {
const serverConfig = serverInfo.config;
const usedIn = serverInfo.usedIn || [];
const command = serverConfig.command || serverConfig.url || 'N/A';
const args = serverConfig.args || [];
// Get the actual name to use when adding
const originalName = serverInfo.originalName || serverName;
const hasVariant = serverInfo.originalName && serverInfo.originalName !== serverName;
// Get source project info
const sourceProject = serverInfo.sourceProject;
const sourceProjectName = sourceProject ? (sourceProject.split('\\').pop() || sourceProject.split('/').pop()) : null;
// Generate args preview
const argsPreview = args.length > 0 ? args.slice(0, 3).join(' ') + (args.length > 3 ? '...' : '') : '';
// Check if already in Codex
const alreadyInCodex = codexMcpServers && codexMcpServers[originalName];
return `
<div class="mcp-server-card mcp-server-available bg-card border ${alreadyInCodex ? 'border-success/50' : 'border-border'} border-dashed rounded-lg p-4 hover:shadow-md hover:border-solid transition-all">
<div class="flex items-start justify-between mb-3">
<div class="flex items-center gap-2 flex-wrap">
<span><i data-lucide="circle-dashed" class="w-5 h-5 text-muted-foreground"></i></span>
<h4 class="font-semibold text-foreground">${escapeHtml(originalName)}</h4>
<span class="text-xs px-2 py-0.5 bg-primary/10 text-primary rounded-full">Claude</span>
${hasVariant ? `
<span class="text-xs px-2 py-0.5 bg-warning/20 text-warning rounded-full" title="Different config from: ${escapeHtml(sourceProject || '')}">
${escapeHtml(sourceProjectName || 'variant')}
</span>
` : ''}
${alreadyInCodex ? `<span class="text-xs px-2 py-0.5 bg-success/10 text-success rounded-full">${t('mcp.codex.alreadyAdded')}</span>` : ''}
</div>
${!alreadyInCodex ? `
<button class="px-3 py-1 text-xs bg-orange-500 text-white rounded hover:opacity-90 transition-opacity"
onclick="copyClaudeServerToCodex('${escapeHtml(originalName)}', ${JSON.stringify(serverConfig).replace(/'/g, "&#39;")})"
title="${t('mcp.codex.copyToCodex')}">
<i data-lucide="arrow-right" class="w-3.5 h-3.5 inline"></i> Codex
</button>
` : ''}
</div>
<div class="mcp-server-details text-sm space-y-1">
<div class="flex items-center gap-2 text-muted-foreground">
<span class="font-mono text-xs bg-muted px-1.5 py-0.5 rounded">${t('mcp.cmd')}</span>
<span class="truncate" title="${escapeHtml(command)}">${escapeHtml(command)}</span>
</div>
${argsPreview ? `
<div class="flex items-start gap-2 text-muted-foreground">
<span class="font-mono text-xs bg-muted px-1.5 py-0.5 rounded shrink-0">${t('mcp.args')}</span>
<span class="text-xs font-mono truncate" title="${escapeHtml(args.join(' '))}">${escapeHtml(argsPreview)}</span>
</div>
` : ''}
<div class="flex items-center gap-2 text-muted-foreground">
<span class="text-xs">${t('mcp.usedInCount').replace('{count}', usedIn.length).replace('{s}', usedIn.length !== 1 ? 's' : '')}</span>
${sourceProjectName ? `<span class="text-xs text-muted-foreground/70">• ${t('mcp.from')} ${escapeHtml(sourceProjectName)}</span>` : ''}
</div>
</div>
</div>
`;
}
// ========================================
// Codex MCP Server Card Renderer
// ========================================
@@ -825,14 +965,17 @@ function renderCodexServerCard(serverName, serverConfig) {
: `<span class="text-xs px-2 py-0.5 bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300 rounded-full">STDIO</span>`;
return `
<div class="mcp-server-card bg-card border border-orange-200 dark:border-orange-800 rounded-lg p-4 hover:shadow-md transition-all ${!isEnabled ? 'opacity-60' : ''}"
<div class="mcp-server-card bg-card border border-orange-200 dark:border-orange-800 rounded-lg p-4 hover:shadow-md transition-all cursor-pointer ${!isEnabled ? 'opacity-60' : ''}"
data-server-name="${escapeHtml(serverName)}"
data-server-config="${escapeHtml(JSON.stringify(serverConfig))}"
data-cli-type="codex">
data-cli-type="codex"
data-action="view-details-codex"
title="${t('mcp.clickToEdit')}">
<div class="flex items-start justify-between mb-3">
<div class="flex items-center gap-2">
<div class="flex items-center gap-2 flex-wrap">
<span>${isEnabled ? '<i data-lucide="check-circle" class="w-5 h-5 text-orange-500"></i>' : '<i data-lucide="circle" class="w-5 h-5 text-muted-foreground"></i>'}</span>
<h4 class="font-semibold text-foreground">${escapeHtml(serverName)}</h4>
<span class="text-xs px-2 py-0.5 bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-300 rounded-full">Codex</span>
${typeBadge}
</div>
<label class="mcp-toggle relative inline-flex items-center cursor-pointer" onclick="event.stopPropagation()">
@@ -1041,13 +1184,22 @@ function attachMcpEventListeners() {
});
});
// View details - click on server card
// View details / Edit - click on Claude server card
document.querySelectorAll('.mcp-server-card[data-action="view-details"]').forEach(card => {
card.addEventListener('click', (e) => {
const serverName = card.dataset.serverName;
const serverConfig = JSON.parse(card.dataset.serverConfig);
const serverSource = card.dataset.serverSource;
showMcpDetails(serverName, serverConfig, serverSource);
showMcpEditModal(serverName, serverConfig, serverSource, 'claude');
});
});
// View details / Edit - click on Codex server card
document.querySelectorAll('.mcp-server-card[data-action="view-details-codex"]').forEach(card => {
card.addEventListener('click', (e) => {
const serverName = card.dataset.serverName;
const serverConfig = JSON.parse(card.dataset.serverConfig);
showMcpEditModal(serverName, serverConfig, 'codex', 'codex');
});
});
@@ -1068,15 +1220,39 @@ function attachMcpEventListeners() {
}
// ========================================
// MCP Details Modal
// MCP Edit Modal (replaces Details Modal)
// ========================================
function showMcpDetails(serverName, serverConfig, serverSource) {
// Store current editing context
let mcpEditContext = {
serverName: null,
serverConfig: null,
serverSource: null,
cliType: 'claude'
};
function showMcpDetails(serverName, serverConfig, serverSource, cliType = 'claude') {
showMcpEditModal(serverName, serverConfig, serverSource, cliType);
}
function showMcpEditModal(serverName, serverConfig, serverSource, cliType = 'claude') {
const modal = document.getElementById('mcpDetailsModal');
const modalBody = document.getElementById('mcpDetailsModalBody');
if (!modal || !modalBody) return;
// Store editing context
mcpEditContext = {
serverName,
serverConfig: JSON.parse(JSON.stringify(serverConfig)), // Deep clone
serverSource,
cliType
};
// Check if editable (enterprise is read-only)
const isReadOnly = serverSource === 'enterprise';
const isCodex = cliType === 'codex';
// Build source badge
let sourceBadge = '';
if (serverSource === 'enterprise') {
@@ -1085,74 +1261,271 @@ function showMcpDetails(serverName, serverConfig, serverSource) {
sourceBadge = `<span class="inline-flex items-center px-2 py-1 text-xs font-semibold rounded-full bg-success/10 text-success">${t('mcp.sourceGlobal')}</span>`;
} else if (serverSource === 'project') {
sourceBadge = `<span class="inline-flex items-center px-2 py-1 text-xs font-semibold rounded-full bg-primary/10 text-primary">${t('mcp.sourceProject')}</span>`;
} else if (isCodex) {
sourceBadge = `<span class="inline-flex items-center px-2 py-1 text-xs font-semibold rounded-full bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-300">Codex</span>`;
}
// Build environment variables display
let envHtml = '';
if (serverConfig.env && Object.keys(serverConfig.env).length > 0) {
envHtml = '<div class="mt-4"><h4 class="font-semibold text-sm text-foreground mb-2">' + t('mcp.env') + '</h4><div class="bg-muted rounded-lg p-3 space-y-1 font-mono text-xs">';
for (const [key, value] of Object.entries(serverConfig.env)) {
envHtml += `<div class="flex items-start gap-2"><span class="text-muted-foreground shrink-0">${escapeHtml(key)}:</span><span class="text-foreground break-all">${escapeHtml(value)}</span></div>`;
}
envHtml += '</div></div>';
} else {
envHtml = '<div class="mt-4"><h4 class="font-semibold text-sm text-foreground mb-2">' + t('mcp.env') + '</h4><p class="text-sm text-muted-foreground">' + t('mcp.detailsModal.noEnv') + '</p></div>';
}
// Format args and env for textarea
const argsText = (serverConfig.args || []).join('\n');
const envText = Object.entries(serverConfig.env || {}).map(([k, v]) => `${k}=${v}`).join('\n');
// Build edit form HTML
modalBody.innerHTML = `
<div class="space-y-4">
<!-- Server Name and Source -->
<div>
<label class="text-xs font-semibold text-muted-foreground uppercase tracking-wide">${t('mcp.detailsModal.serverName')}</label>
<div class="mt-1 flex items-center gap-2">
<h3 class="text-xl font-bold text-foreground">${escapeHtml(serverName)}</h3>
<input type="text" id="mcpEditName" value="${escapeHtml(serverName)}"
class="text-lg font-bold text-foreground bg-transparent border-b border-border focus:border-primary outline-none px-1 py-0.5 flex-1"
${isReadOnly ? 'disabled' : ''}
placeholder="${t('mcp.editModal.serverNamePlaceholder')}">
${sourceBadge}
</div>
</div>
<!-- Configuration -->
<!-- Command/URL -->
<div>
<h4 class="font-semibold text-sm text-foreground mb-2">${t('mcp.detailsModal.configuration')}</h4>
<div class="space-y-2">
<!-- Command -->
<div class="flex items-start gap-3">
<span class="font-mono text-xs bg-muted px-2 py-1 rounded shrink-0">${t('mcp.cmd')}</span>
<code class="text-sm font-mono text-foreground break-all">${escapeHtml(serverConfig.command || serverConfig.url || 'N/A')}</code>
</div>
<label class="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-1 block">
${serverConfig.url ? t('mcp.url') : t('mcp.cmd')}
</label>
<input type="text" id="mcpEditCommand" value="${escapeHtml(serverConfig.command || serverConfig.url || '')}"
class="w-full px-3 py-2 text-sm font-mono bg-muted border border-border rounded-lg focus:border-primary outline-none"
${isReadOnly ? 'disabled' : ''}
placeholder="${serverConfig.url ? 'https://...' : 'npx, node, python...'}">
</div>
<!-- Arguments -->
${serverConfig.args && serverConfig.args.length > 0 ? `
<div class="flex items-start gap-3">
<span class="font-mono text-xs bg-muted px-2 py-1 rounded shrink-0">${t('mcp.args')}</span>
<div class="flex-1 space-y-1">
${serverConfig.args.map((arg, index) => `
<div class="text-sm font-mono text-foreground flex items-center gap-2">
<span class="text-muted-foreground">[${index}]</span>
<code class="break-all">${escapeHtml(arg)}</code>
</div>
`).join('')}
</div>
</div>
` : ''}
</div>
<!-- Arguments -->
<div>
<label class="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-1 block">
${t('mcp.args')} <span class="font-normal">(${t('mcp.editModal.onePerLine')})</span>
</label>
<textarea id="mcpEditArgs" rows="3"
class="w-full px-3 py-2 text-sm font-mono bg-muted border border-border rounded-lg focus:border-primary outline-none resize-none"
${isReadOnly ? 'disabled' : ''}
placeholder="-y&#10;package-name">${escapeHtml(argsText)}</textarea>
</div>
<!-- Environment Variables -->
${envHtml}
<!-- Raw JSON -->
<div>
<h4 class="font-semibold text-sm text-foreground mb-2">Raw JSON</h4>
<pre class="bg-muted rounded-lg p-3 text-xs font-mono overflow-x-auto">${escapeHtml(JSON.stringify(serverConfig, null, 2))}</pre>
<label class="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-1 block">
${t('mcp.env')} <span class="font-normal">(KEY=VALUE ${t('mcp.editModal.onePerLine')})</span>
</label>
<textarea id="mcpEditEnv" rows="3"
class="w-full px-3 py-2 text-sm font-mono bg-muted border border-border rounded-lg focus:border-primary outline-none resize-none"
${isReadOnly ? 'disabled' : ''}
placeholder="API_KEY=your-key&#10;DEBUG=true">${escapeHtml(envText)}</textarea>
</div>
${isCodex ? `
<!-- Codex-specific: enabled_tools -->
<div>
<label class="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-1 block">
${t('mcp.codex.enabledTools')} <span class="font-normal">(${t('mcp.editModal.onePerLine')})</span>
</label>
<textarea id="mcpEditEnabledTools" rows="2"
class="w-full px-3 py-2 text-sm font-mono bg-muted border border-border rounded-lg focus:border-primary outline-none resize-none"
${isReadOnly ? 'disabled' : ''}
placeholder="tool1&#10;tool2">${escapeHtml((serverConfig.enabled_tools || []).join('\n'))}</textarea>
</div>
` : ''}
<!-- Raw JSON Preview (collapsible) -->
<details class="group">
<summary class="text-xs font-semibold text-muted-foreground uppercase tracking-wide cursor-pointer flex items-center gap-1">
<i data-lucide="chevron-right" class="w-3 h-3 transition-transform group-open:rotate-90"></i>
Raw JSON
</summary>
<pre id="mcpEditJsonPreview" class="mt-2 bg-muted rounded-lg p-3 text-xs font-mono overflow-x-auto">${escapeHtml(JSON.stringify(serverConfig, null, 2))}</pre>
</details>
<!-- Action Buttons -->
${!isReadOnly ? `
<div class="flex items-center justify-between pt-4 border-t border-border">
<div class="flex items-center gap-2">
${serverSource === 'project' || isCodex ? `
<button onclick="deleteMcpFromEdit()" class="px-4 py-2 text-sm text-destructive hover:bg-destructive/10 rounded-lg transition-colors flex items-center gap-1.5">
<i data-lucide="trash-2" class="w-4 h-4"></i>
${t('mcp.editModal.delete')}
</button>
` : ''}
</div>
<div class="flex items-center gap-2">
<button onclick="closeMcpEditModal()" class="px-4 py-2 text-sm text-muted-foreground hover:bg-muted rounded-lg transition-colors">
${t('common.cancel')}
</button>
<button onclick="saveMcpEdit()" class="px-4 py-2 text-sm bg-primary text-primary-foreground rounded-lg hover:opacity-90 transition-opacity flex items-center gap-1.5">
<i data-lucide="check" class="w-4 h-4"></i>
${t('mcp.editModal.save')}
</button>
</div>
</div>
` : `
<div class="flex items-center justify-end pt-4 border-t border-border">
<button onclick="closeMcpEditModal()" class="px-4 py-2 text-sm bg-muted text-foreground rounded-lg hover:bg-muted/80 transition-colors">
${t('common.close')}
</button>
</div>
`}
</div>
`;
// Update modal title
const modalTitle = modal.querySelector('h2');
if (modalTitle) {
modalTitle.textContent = isReadOnly ? t('mcp.detailsModal.title') : t('mcp.editModal.title');
}
// Show modal
modal.classList.remove('hidden');
// Re-initialize Lucide icons in modal
if (typeof lucide !== 'undefined') lucide.createIcons();
// Add input listeners to update JSON preview
if (!isReadOnly) {
['mcpEditCommand', 'mcpEditArgs', 'mcpEditEnv', 'mcpEditEnabledTools'].forEach(id => {
const el = document.getElementById(id);
if (el) {
el.addEventListener('input', updateMcpEditJsonPreview);
}
});
}
}
function closeMcpEditModal() {
const modal = document.getElementById('mcpDetailsModal');
if (modal) {
modal.classList.add('hidden');
}
mcpEditContext = { serverName: null, serverConfig: null, serverSource: null, cliType: 'claude' };
}
function updateMcpEditJsonPreview() {
const preview = document.getElementById('mcpEditJsonPreview');
if (!preview) return;
const config = buildConfigFromEditForm();
preview.textContent = JSON.stringify(config, null, 2);
}
function buildConfigFromEditForm() {
const command = document.getElementById('mcpEditCommand')?.value.trim() || '';
const argsText = document.getElementById('mcpEditArgs')?.value.trim() || '';
const envText = document.getElementById('mcpEditEnv')?.value.trim() || '';
const enabledToolsEl = document.getElementById('mcpEditEnabledTools');
// Build config
const config = {};
// Command or URL
if (mcpEditContext.serverConfig?.url) {
config.url = command;
} else {
config.command = command;
}
// Args
if (argsText) {
config.args = argsText.split('\n').map(a => a.trim()).filter(a => a);
}
// Env
if (envText) {
config.env = {};
envText.split('\n').forEach(line => {
const trimmed = line.trim();
if (trimmed && trimmed.includes('=')) {
const eqIndex = trimmed.indexOf('=');
const key = trimmed.substring(0, eqIndex).trim();
const value = trimmed.substring(eqIndex + 1).trim();
if (key) {
config.env[key] = value;
}
}
});
}
// Codex-specific: enabled_tools
if (enabledToolsEl) {
const toolsText = enabledToolsEl.value.trim();
if (toolsText) {
config.enabled_tools = toolsText.split('\n').map(t => t.trim()).filter(t => t);
}
}
return config;
}
async function saveMcpEdit() {
const newName = document.getElementById('mcpEditName')?.value.trim();
if (!newName) {
showRefreshToast(t('mcp.editModal.nameRequired'), 'error');
return;
}
const newConfig = buildConfigFromEditForm();
if (!newConfig.command && !newConfig.url) {
showRefreshToast(t('mcp.editModal.commandRequired'), 'error');
return;
}
const { serverName, serverSource, cliType } = mcpEditContext;
const nameChanged = newName !== serverName;
try {
if (cliType === 'codex') {
// Codex MCP update
// If name changed, remove old and add new
if (nameChanged) {
await removeCodexMcpServer(serverName);
}
await addCodexMcpServer(newName, newConfig);
} else if (serverSource === 'global') {
// Global MCP update
if (nameChanged) {
await removeGlobalMcpServer(serverName);
}
await addGlobalMcpServer(newName, newConfig);
} else if (serverSource === 'project') {
// Project MCP update
if (nameChanged) {
await removeMcpServerFromProject(serverName);
}
await copyMcpServerToProject(newName, newConfig, 'mcp');
}
closeMcpEditModal();
showRefreshToast(t('mcp.editModal.saved', { name: newName }), 'success');
} catch (err) {
console.error('Failed to save MCP edit:', err);
showRefreshToast(t('mcp.editModal.saveFailed') + ': ' + err.message, 'error');
}
}
async function deleteMcpFromEdit() {
const { serverName, serverSource, cliType } = mcpEditContext;
if (!confirm(t('mcp.editModal.deleteConfirm', { name: serverName }))) {
return;
}
try {
if (cliType === 'codex') {
await removeCodexMcpServer(serverName);
} else if (serverSource === 'global') {
await removeGlobalMcpServer(serverName);
} else if (serverSource === 'project') {
await removeMcpServerFromProject(serverName);
}
closeMcpEditModal();
showRefreshToast(t('mcp.editModal.deleted', { name: serverName }), 'success');
} catch (err) {
console.error('Failed to delete MCP:', err);
showRefreshToast(t('mcp.editModal.deleteFailed') + ': ' + err.message, 'error');
}
}
// ========================================

View File

@@ -345,6 +345,7 @@ function renderHotspotsColumn() {
var mostRead = memoryStats.mostRead || [];
var mostEdited = memoryStats.mostEdited || [];
var mostMentioned = memoryStats.mostMentioned || [];
container.innerHTML = '<div class="memory-section">' +
'<div class="section-header">' +
@@ -371,6 +372,10 @@ function renderHotspotsColumn() {
'<h4 class="hotspot-list-title"><i data-lucide="pencil" class="w-3.5 h-3.5"></i> ' + t('memory.mostEdited') + '</h4>' +
renderHotspotList(mostEdited, 'edit') +
'</div>' +
'<div class="hotspot-list-container">' +
'<h4 class="hotspot-list-title"><i data-lucide="message-circle" class="w-3.5 h-3.5"></i> ' + t('memory.mostMentioned') + '</h4>' +
renderTopicList(mostMentioned) +
'</div>' +
'</div>' +
'</div>';
@@ -380,7 +385,7 @@ function renderHotspotsColumn() {
function renderHotspotList(items, type) {
if (!items || items.length === 0) {
return '<div class="hotspot-empty">' +
'<i data-lucide="inbox" class="w-6 h-6"></i>' +
'<i data-lucide="inbox" class="w-5 h-5"></i>' +
'<p>' + t('memory.noData') + '</p>' +
'</div>';
}
@@ -407,6 +412,34 @@ function renderHotspotList(items, type) {
'</div>';
}
function renderTopicList(items) {
if (!items || items.length === 0) {
return '<div class="hotspot-empty">' +
'<i data-lucide="inbox" class="w-5 h-5"></i>' +
'<p>' + t('memory.noData') + '</p>' +
'</div>';
}
return '<div class="hotspot-list topic-list">' +
items.map(function(item, index) {
var heat = item.heat || item.count || 0;
var heatClass = heat > 10 ? 'high' : heat > 5 ? 'medium' : 'low';
var preview = item.preview || item.topic || 'Unknown';
return '<div class="hotspot-item topic-item">' +
'<div class="hotspot-rank">' + (index + 1) + '</div>' +
'<div class="hotspot-info">' +
'<div class="hotspot-name topic-preview" title="' + escapeHtml(item.topic || '') + '">' + escapeHtml(preview) + '</div>' +
'</div>' +
'<div class="hotspot-heat ' + heatClass + '">' +
'<span class="heat-badge">' + heat + '</span>' +
'<i data-lucide="message-circle" class="w-3 h-3"></i>' +
'</div>' +
'</div>';
}).join('') +
'</div>';
}
// ========== Center Column: Memory Graph ==========
// Store graph state for zoom/pan
var graphZoom = null;
@@ -458,7 +491,7 @@ function renderMemoryGraph(graphData) {
var container = document.getElementById('memoryGraphSvg');
if (container) {
container.innerHTML = '<div class="graph-empty-state">' +
'<i data-lucide="network" class="w-12 h-12"></i>' +
'<i data-lucide="network" class="w-8 h-8"></i>' +
'<p>' + t('memory.noGraphData') + '</p>' +
'</div>';
if (window.lucide) lucide.createIcons();
@@ -471,7 +504,7 @@ function renderMemoryGraph(graphData) {
var container = document.getElementById('memoryGraphSvg');
if (container) {
container.innerHTML = '<div class="graph-error">' +
'<i data-lucide="alert-triangle" class="w-8 h-8"></i>' +
'<i data-lucide="alert-triangle" class="w-6 h-6"></i>' +
'<p>' + t('memory.d3NotLoaded') + '</p>' +
'</div>';
if (window.lucide) lucide.createIcons();
@@ -767,7 +800,7 @@ function renderContextColumn() {
function renderContextTimeline(prompts) {
if (!prompts || prompts.length === 0) {
return '<div class="context-empty">' +
'<i data-lucide="inbox" class="w-8 h-8"></i>' +
'<i data-lucide="inbox" class="w-6 h-6"></i>' +
'<p>' + t('memory.noRecentActivity') + '</p>' +
'</div>';
}

View File

@@ -248,7 +248,7 @@
</button>
<div class="flex items-center gap-2 text-lg font-semibold text-primary">
<i data-lucide="workflow" class="w-6 h-6"></i>
<span class="hidden sm:inline">Claude Code Workflow</span>
<span data-i18n="app.brand">Claude Code Workflow</span>
</div>
</div>