mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-10 02:24:35 +08:00
Add coverage prettification and sorting functionality
- Introduced `prettify.css` for syntax highlighting in coverage reports. - Added `prettify.js` to handle code formatting and highlighting. - Included `sort-arrow-sprite.png` for sort indicators in the coverage table. - Implemented `sorter.js` to enable sorting and filtering of coverage summary tables. - Added a search box for filtering table rows based on user input.
This commit is contained in:
224
ccw/frontend/coverage/base.css
Normal file
224
ccw/frontend/coverage/base.css
Normal file
@@ -0,0 +1,224 @@
|
||||
body, html {
|
||||
margin:0; padding: 0;
|
||||
height: 100%;
|
||||
}
|
||||
body {
|
||||
font-family: Helvetica Neue, Helvetica, Arial;
|
||||
font-size: 14px;
|
||||
color:#333;
|
||||
}
|
||||
.small { font-size: 12px; }
|
||||
*, *:after, *:before {
|
||||
-webkit-box-sizing:border-box;
|
||||
-moz-box-sizing:border-box;
|
||||
box-sizing:border-box;
|
||||
}
|
||||
h1 { font-size: 20px; margin: 0;}
|
||||
h2 { font-size: 14px; }
|
||||
pre {
|
||||
font: 12px/1.4 Consolas, "Liberation Mono", Menlo, Courier, monospace;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
-moz-tab-size: 2;
|
||||
-o-tab-size: 2;
|
||||
tab-size: 2;
|
||||
}
|
||||
a { color:#0074D9; text-decoration:none; }
|
||||
a:hover { text-decoration:underline; }
|
||||
.strong { font-weight: bold; }
|
||||
.space-top1 { padding: 10px 0 0 0; }
|
||||
.pad2y { padding: 20px 0; }
|
||||
.pad1y { padding: 10px 0; }
|
||||
.pad2x { padding: 0 20px; }
|
||||
.pad2 { padding: 20px; }
|
||||
.pad1 { padding: 10px; }
|
||||
.space-left2 { padding-left:55px; }
|
||||
.space-right2 { padding-right:20px; }
|
||||
.center { text-align:center; }
|
||||
.clearfix { display:block; }
|
||||
.clearfix:after {
|
||||
content:'';
|
||||
display:block;
|
||||
height:0;
|
||||
clear:both;
|
||||
visibility:hidden;
|
||||
}
|
||||
.fl { float: left; }
|
||||
@media only screen and (max-width:640px) {
|
||||
.col3 { width:100%; max-width:100%; }
|
||||
.hide-mobile { display:none!important; }
|
||||
}
|
||||
|
||||
.quiet {
|
||||
color: #7f7f7f;
|
||||
color: rgba(0,0,0,0.5);
|
||||
}
|
||||
.quiet a { opacity: 0.7; }
|
||||
|
||||
.fraction {
|
||||
font-family: Consolas, 'Liberation Mono', Menlo, Courier, monospace;
|
||||
font-size: 10px;
|
||||
color: #555;
|
||||
background: #E8E8E8;
|
||||
padding: 4px 5px;
|
||||
border-radius: 3px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
div.path a:link, div.path a:visited { color: #333; }
|
||||
table.coverage {
|
||||
border-collapse: collapse;
|
||||
margin: 10px 0 0 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
table.coverage td {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
vertical-align: top;
|
||||
}
|
||||
table.coverage td.line-count {
|
||||
text-align: right;
|
||||
padding: 0 5px 0 20px;
|
||||
}
|
||||
table.coverage td.line-coverage {
|
||||
text-align: right;
|
||||
padding-right: 10px;
|
||||
min-width:20px;
|
||||
}
|
||||
|
||||
table.coverage td span.cline-any {
|
||||
display: inline-block;
|
||||
padding: 0 5px;
|
||||
width: 100%;
|
||||
}
|
||||
.missing-if-branch {
|
||||
display: inline-block;
|
||||
margin-right: 5px;
|
||||
border-radius: 3px;
|
||||
position: relative;
|
||||
padding: 0 4px;
|
||||
background: #333;
|
||||
color: yellow;
|
||||
}
|
||||
|
||||
.skip-if-branch {
|
||||
display: none;
|
||||
margin-right: 10px;
|
||||
position: relative;
|
||||
padding: 0 4px;
|
||||
background: #ccc;
|
||||
color: white;
|
||||
}
|
||||
.missing-if-branch .typ, .skip-if-branch .typ {
|
||||
color: inherit !important;
|
||||
}
|
||||
.coverage-summary {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
}
|
||||
.coverage-summary tr { border-bottom: 1px solid #bbb; }
|
||||
.keyline-all { border: 1px solid #ddd; }
|
||||
.coverage-summary td, .coverage-summary th { padding: 10px; }
|
||||
.coverage-summary tbody { border: 1px solid #bbb; }
|
||||
.coverage-summary td { border-right: 1px solid #bbb; }
|
||||
.coverage-summary td:last-child { border-right: none; }
|
||||
.coverage-summary th {
|
||||
text-align: left;
|
||||
font-weight: normal;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.coverage-summary th.file { border-right: none !important; }
|
||||
.coverage-summary th.pct { }
|
||||
.coverage-summary th.pic,
|
||||
.coverage-summary th.abs,
|
||||
.coverage-summary td.pct,
|
||||
.coverage-summary td.abs { text-align: right; }
|
||||
.coverage-summary td.file { white-space: nowrap; }
|
||||
.coverage-summary td.pic { min-width: 120px !important; }
|
||||
.coverage-summary tfoot td { }
|
||||
|
||||
.coverage-summary .sorter {
|
||||
height: 10px;
|
||||
width: 7px;
|
||||
display: inline-block;
|
||||
margin-left: 0.5em;
|
||||
background: url(sort-arrow-sprite.png) no-repeat scroll 0 0 transparent;
|
||||
}
|
||||
.coverage-summary .sorted .sorter {
|
||||
background-position: 0 -20px;
|
||||
}
|
||||
.coverage-summary .sorted-desc .sorter {
|
||||
background-position: 0 -10px;
|
||||
}
|
||||
.status-line { height: 10px; }
|
||||
/* yellow */
|
||||
.cbranch-no { background: yellow !important; color: #111; }
|
||||
/* dark red */
|
||||
.red.solid, .status-line.low, .low .cover-fill { background:#C21F39 }
|
||||
.low .chart { border:1px solid #C21F39 }
|
||||
.highlighted,
|
||||
.highlighted .cstat-no, .highlighted .fstat-no, .highlighted .cbranch-no{
|
||||
background: #C21F39 !important;
|
||||
}
|
||||
/* medium red */
|
||||
.cstat-no, .fstat-no, .cbranch-no, .cbranch-no { background:#F6C6CE }
|
||||
/* light red */
|
||||
.low, .cline-no { background:#FCE1E5 }
|
||||
/* light green */
|
||||
.high, .cline-yes { background:rgb(230,245,208) }
|
||||
/* medium green */
|
||||
.cstat-yes { background:rgb(161,215,106) }
|
||||
/* dark green */
|
||||
.status-line.high, .high .cover-fill { background:rgb(77,146,33) }
|
||||
.high .chart { border:1px solid rgb(77,146,33) }
|
||||
/* dark yellow (gold) */
|
||||
.status-line.medium, .medium .cover-fill { background: #f9cd0b; }
|
||||
.medium .chart { border:1px solid #f9cd0b; }
|
||||
/* light yellow */
|
||||
.medium { background: #fff4c2; }
|
||||
|
||||
.cstat-skip { background: #ddd; color: #111; }
|
||||
.fstat-skip { background: #ddd; color: #111 !important; }
|
||||
.cbranch-skip { background: #ddd !important; color: #111; }
|
||||
|
||||
span.cline-neutral { background: #eaeaea; }
|
||||
|
||||
.coverage-summary td.empty {
|
||||
opacity: .5;
|
||||
padding-top: 4px;
|
||||
padding-bottom: 4px;
|
||||
line-height: 1;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.cover-fill, .cover-empty {
|
||||
display:inline-block;
|
||||
height: 12px;
|
||||
}
|
||||
.chart {
|
||||
line-height: 0;
|
||||
}
|
||||
.cover-empty {
|
||||
background: white;
|
||||
}
|
||||
.cover-full {
|
||||
border-right: none !important;
|
||||
}
|
||||
pre.prettyprint {
|
||||
border: none !important;
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
.com { color: #999 !important; }
|
||||
.ignore-none { color: #999; font-weight: normal; }
|
||||
|
||||
.wrapper {
|
||||
min-height: 100%;
|
||||
height: auto !important;
|
||||
height: 100%;
|
||||
margin: 0 auto -48px;
|
||||
}
|
||||
.footer, .push {
|
||||
height: 48px;
|
||||
}
|
||||
87
ccw/frontend/coverage/block-navigation.js
Normal file
87
ccw/frontend/coverage/block-navigation.js
Normal file
@@ -0,0 +1,87 @@
|
||||
/* eslint-disable */
|
||||
var jumpToCode = (function init() {
|
||||
// Classes of code we would like to highlight in the file view
|
||||
var missingCoverageClasses = ['.cbranch-no', '.cstat-no', '.fstat-no'];
|
||||
|
||||
// Elements to highlight in the file listing view
|
||||
var fileListingElements = ['td.pct.low'];
|
||||
|
||||
// We don't want to select elements that are direct descendants of another match
|
||||
var notSelector = ':not(' + missingCoverageClasses.join('):not(') + ') > '; // becomes `:not(a):not(b) > `
|
||||
|
||||
// Selector that finds elements on the page to which we can jump
|
||||
var selector =
|
||||
fileListingElements.join(', ') +
|
||||
', ' +
|
||||
notSelector +
|
||||
missingCoverageClasses.join(', ' + notSelector); // becomes `:not(a):not(b) > a, :not(a):not(b) > b`
|
||||
|
||||
// The NodeList of matching elements
|
||||
var missingCoverageElements = document.querySelectorAll(selector);
|
||||
|
||||
var currentIndex;
|
||||
|
||||
function toggleClass(index) {
|
||||
missingCoverageElements
|
||||
.item(currentIndex)
|
||||
.classList.remove('highlighted');
|
||||
missingCoverageElements.item(index).classList.add('highlighted');
|
||||
}
|
||||
|
||||
function makeCurrent(index) {
|
||||
toggleClass(index);
|
||||
currentIndex = index;
|
||||
missingCoverageElements.item(index).scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
inline: 'center'
|
||||
});
|
||||
}
|
||||
|
||||
function goToPrevious() {
|
||||
var nextIndex = 0;
|
||||
if (typeof currentIndex !== 'number' || currentIndex === 0) {
|
||||
nextIndex = missingCoverageElements.length - 1;
|
||||
} else if (missingCoverageElements.length > 1) {
|
||||
nextIndex = currentIndex - 1;
|
||||
}
|
||||
|
||||
makeCurrent(nextIndex);
|
||||
}
|
||||
|
||||
function goToNext() {
|
||||
var nextIndex = 0;
|
||||
|
||||
if (
|
||||
typeof currentIndex === 'number' &&
|
||||
currentIndex < missingCoverageElements.length - 1
|
||||
) {
|
||||
nextIndex = currentIndex + 1;
|
||||
}
|
||||
|
||||
makeCurrent(nextIndex);
|
||||
}
|
||||
|
||||
return function jump(event) {
|
||||
if (
|
||||
document.getElementById('fileSearch') === document.activeElement &&
|
||||
document.activeElement != null
|
||||
) {
|
||||
// if we're currently focused on the search input, we don't want to navigate
|
||||
return;
|
||||
}
|
||||
|
||||
switch (event.which) {
|
||||
case 78: // n
|
||||
case 74: // j
|
||||
goToNext();
|
||||
break;
|
||||
case 66: // b
|
||||
case 75: // k
|
||||
case 80: // p
|
||||
goToPrevious();
|
||||
break;
|
||||
}
|
||||
};
|
||||
})();
|
||||
window.addEventListener('keydown', jumpToCode);
|
||||
1553
ccw/frontend/coverage/coverage-final.json
Normal file
1553
ccw/frontend/coverage/coverage-final.json
Normal file
File diff suppressed because one or more lines are too long
BIN
ccw/frontend/coverage/favicon.png
Normal file
BIN
ccw/frontend/coverage/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 445 B |
BIN
ccw/frontend/coverage/index.html
Normal file
BIN
ccw/frontend/coverage/index.html
Normal file
Binary file not shown.
1
ccw/frontend/coverage/prettify.css
Normal file
1
ccw/frontend/coverage/prettify.css
Normal file
@@ -0,0 +1 @@
|
||||
.pln{color:#000}@media screen{.str{color:#080}.kwd{color:#008}.com{color:#800}.typ{color:#606}.lit{color:#066}.pun,.opn,.clo{color:#660}.tag{color:#008}.atn{color:#606}.atv{color:#080}.dec,.var{color:#606}.fun{color:red}}@media print,projection{.str{color:#060}.kwd{color:#006;font-weight:bold}.com{color:#600;font-style:italic}.typ{color:#404;font-weight:bold}.lit{color:#044}.pun,.opn,.clo{color:#440}.tag{color:#006;font-weight:bold}.atn{color:#404}.atv{color:#060}}pre.prettyprint{padding:2px;border:1px solid #888}ol.linenums{margin-top:0;margin-bottom:0}li.L0,li.L1,li.L2,li.L3,li.L5,li.L6,li.L7,li.L8{list-style-type:none}li.L1,li.L3,li.L5,li.L7,li.L9{background:#eee}
|
||||
2
ccw/frontend/coverage/prettify.js
Normal file
2
ccw/frontend/coverage/prettify.js
Normal file
File diff suppressed because one or more lines are too long
BIN
ccw/frontend/coverage/sort-arrow-sprite.png
Normal file
BIN
ccw/frontend/coverage/sort-arrow-sprite.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 138 B |
210
ccw/frontend/coverage/sorter.js
Normal file
210
ccw/frontend/coverage/sorter.js
Normal file
@@ -0,0 +1,210 @@
|
||||
/* eslint-disable */
|
||||
var addSorting = (function() {
|
||||
'use strict';
|
||||
var cols,
|
||||
currentSort = {
|
||||
index: 0,
|
||||
desc: false
|
||||
};
|
||||
|
||||
// returns the summary table element
|
||||
function getTable() {
|
||||
return document.querySelector('.coverage-summary');
|
||||
}
|
||||
// returns the thead element of the summary table
|
||||
function getTableHeader() {
|
||||
return getTable().querySelector('thead tr');
|
||||
}
|
||||
// returns the tbody element of the summary table
|
||||
function getTableBody() {
|
||||
return getTable().querySelector('tbody');
|
||||
}
|
||||
// returns the th element for nth column
|
||||
function getNthColumn(n) {
|
||||
return getTableHeader().querySelectorAll('th')[n];
|
||||
}
|
||||
|
||||
function onFilterInput() {
|
||||
const searchValue = document.getElementById('fileSearch').value;
|
||||
const rows = document.getElementsByTagName('tbody')[0].children;
|
||||
|
||||
// Try to create a RegExp from the searchValue. If it fails (invalid regex),
|
||||
// it will be treated as a plain text search
|
||||
let searchRegex;
|
||||
try {
|
||||
searchRegex = new RegExp(searchValue, 'i'); // 'i' for case-insensitive
|
||||
} catch (error) {
|
||||
searchRegex = null;
|
||||
}
|
||||
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const row = rows[i];
|
||||
let isMatch = false;
|
||||
|
||||
if (searchRegex) {
|
||||
// If a valid regex was created, use it for matching
|
||||
isMatch = searchRegex.test(row.textContent);
|
||||
} else {
|
||||
// Otherwise, fall back to the original plain text search
|
||||
isMatch = row.textContent
|
||||
.toLowerCase()
|
||||
.includes(searchValue.toLowerCase());
|
||||
}
|
||||
|
||||
row.style.display = isMatch ? '' : 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// loads the search box
|
||||
function addSearchBox() {
|
||||
var template = document.getElementById('filterTemplate');
|
||||
var templateClone = template.content.cloneNode(true);
|
||||
templateClone.getElementById('fileSearch').oninput = onFilterInput;
|
||||
template.parentElement.appendChild(templateClone);
|
||||
}
|
||||
|
||||
// loads all columns
|
||||
function loadColumns() {
|
||||
var colNodes = getTableHeader().querySelectorAll('th'),
|
||||
colNode,
|
||||
cols = [],
|
||||
col,
|
||||
i;
|
||||
|
||||
for (i = 0; i < colNodes.length; i += 1) {
|
||||
colNode = colNodes[i];
|
||||
col = {
|
||||
key: colNode.getAttribute('data-col'),
|
||||
sortable: !colNode.getAttribute('data-nosort'),
|
||||
type: colNode.getAttribute('data-type') || 'string'
|
||||
};
|
||||
cols.push(col);
|
||||
if (col.sortable) {
|
||||
col.defaultDescSort = col.type === 'number';
|
||||
colNode.innerHTML =
|
||||
colNode.innerHTML + '<span class="sorter"></span>';
|
||||
}
|
||||
}
|
||||
return cols;
|
||||
}
|
||||
// attaches a data attribute to every tr element with an object
|
||||
// of data values keyed by column name
|
||||
function loadRowData(tableRow) {
|
||||
var tableCols = tableRow.querySelectorAll('td'),
|
||||
colNode,
|
||||
col,
|
||||
data = {},
|
||||
i,
|
||||
val;
|
||||
for (i = 0; i < tableCols.length; i += 1) {
|
||||
colNode = tableCols[i];
|
||||
col = cols[i];
|
||||
val = colNode.getAttribute('data-value');
|
||||
if (col.type === 'number') {
|
||||
val = Number(val);
|
||||
}
|
||||
data[col.key] = val;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
// loads all row data
|
||||
function loadData() {
|
||||
var rows = getTableBody().querySelectorAll('tr'),
|
||||
i;
|
||||
|
||||
for (i = 0; i < rows.length; i += 1) {
|
||||
rows[i].data = loadRowData(rows[i]);
|
||||
}
|
||||
}
|
||||
// sorts the table using the data for the ith column
|
||||
function sortByIndex(index, desc) {
|
||||
var key = cols[index].key,
|
||||
sorter = function(a, b) {
|
||||
a = a.data[key];
|
||||
b = b.data[key];
|
||||
return a < b ? -1 : a > b ? 1 : 0;
|
||||
},
|
||||
finalSorter = sorter,
|
||||
tableBody = document.querySelector('.coverage-summary tbody'),
|
||||
rowNodes = tableBody.querySelectorAll('tr'),
|
||||
rows = [],
|
||||
i;
|
||||
|
||||
if (desc) {
|
||||
finalSorter = function(a, b) {
|
||||
return -1 * sorter(a, b);
|
||||
};
|
||||
}
|
||||
|
||||
for (i = 0; i < rowNodes.length; i += 1) {
|
||||
rows.push(rowNodes[i]);
|
||||
tableBody.removeChild(rowNodes[i]);
|
||||
}
|
||||
|
||||
rows.sort(finalSorter);
|
||||
|
||||
for (i = 0; i < rows.length; i += 1) {
|
||||
tableBody.appendChild(rows[i]);
|
||||
}
|
||||
}
|
||||
// removes sort indicators for current column being sorted
|
||||
function removeSortIndicators() {
|
||||
var col = getNthColumn(currentSort.index),
|
||||
cls = col.className;
|
||||
|
||||
cls = cls.replace(/ sorted$/, '').replace(/ sorted-desc$/, '');
|
||||
col.className = cls;
|
||||
}
|
||||
// adds sort indicators for current column being sorted
|
||||
function addSortIndicators() {
|
||||
getNthColumn(currentSort.index).className += currentSort.desc
|
||||
? ' sorted-desc'
|
||||
: ' sorted';
|
||||
}
|
||||
// adds event listeners for all sorter widgets
|
||||
function enableUI() {
|
||||
var i,
|
||||
el,
|
||||
ithSorter = function ithSorter(i) {
|
||||
var col = cols[i];
|
||||
|
||||
return function() {
|
||||
var desc = col.defaultDescSort;
|
||||
|
||||
if (currentSort.index === i) {
|
||||
desc = !currentSort.desc;
|
||||
}
|
||||
sortByIndex(i, desc);
|
||||
removeSortIndicators();
|
||||
currentSort.index = i;
|
||||
currentSort.desc = desc;
|
||||
addSortIndicators();
|
||||
};
|
||||
};
|
||||
for (i = 0; i < cols.length; i += 1) {
|
||||
if (cols[i].sortable) {
|
||||
// add the click event handler on the th so users
|
||||
// dont have to click on those tiny arrows
|
||||
el = getNthColumn(i).querySelector('.sorter').parentElement;
|
||||
if (el.addEventListener) {
|
||||
el.addEventListener('click', ithSorter(i));
|
||||
} else {
|
||||
el.attachEvent('onclick', ithSorter(i));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// adds sorting functionality to the UI
|
||||
return function() {
|
||||
if (!getTable()) {
|
||||
return;
|
||||
}
|
||||
cols = loadColumns();
|
||||
loadData();
|
||||
addSearchBox();
|
||||
addSortIndicators();
|
||||
enableUI();
|
||||
};
|
||||
})();
|
||||
|
||||
window.addEventListener('load', addSorting);
|
||||
@@ -26,14 +26,21 @@
|
||||
},
|
||||
"cliTools": {
|
||||
"title": "CLI Tools",
|
||||
"description": "Configure CLI tool settings",
|
||||
"description": "Configure CLI tool settings. Current default: ",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"default": "Default",
|
||||
"setDefault": "Set as Default",
|
||||
"primaryModel": "Primary Model",
|
||||
"secondaryModel": "Secondary Model",
|
||||
"expand": "Expand for details"
|
||||
"expand": "Expand for details",
|
||||
"envFile": "Environment File (.env)",
|
||||
"envFilePlaceholder": "e.g., ~/.gemini/.env",
|
||||
"envFileHint": "Path to .env file loaded before CLI execution for API keys and environment variables",
|
||||
"saveToConfig": "Save to Config",
|
||||
"saving": "Saving...",
|
||||
"configSaved": "Configuration saved to ~/.claude/cli-tools.json",
|
||||
"configSaveError": "Failed to save configuration"
|
||||
},
|
||||
"display": {
|
||||
"title": "Display Settings",
|
||||
|
||||
@@ -26,14 +26,21 @@
|
||||
},
|
||||
"cliTools": {
|
||||
"title": "CLI 工具",
|
||||
"description": "配置 CLI 工具设置",
|
||||
"description": "配置 CLI 工具设置,当前默认工具:",
|
||||
"enabled": "已启用",
|
||||
"disabled": "已禁用",
|
||||
"default": "默认",
|
||||
"setDefault": "设为默认",
|
||||
"primaryModel": "主模型",
|
||||
"secondaryModel": "辅助模型",
|
||||
"expand": "展开详情"
|
||||
"expand": "展开详情",
|
||||
"envFile": "环境变量文件 (.env)",
|
||||
"envFilePlaceholder": "例如:~/.gemini/.env",
|
||||
"envFileHint": "CLI 执行前加载的 .env 文件路径,用于设置 API Key 等环境变量",
|
||||
"saveToConfig": "保存到配置文件",
|
||||
"saving": "保存中...",
|
||||
"configSaved": "配置已保存到 ~/.claude/cli-tools.json",
|
||||
"configSaveError": "保存配置失败"
|
||||
},
|
||||
"display": {
|
||||
"title": "显示设置",
|
||||
|
||||
@@ -71,8 +71,7 @@ describe('EndpointsPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// confirm() used for delete
|
||||
// @ts-expect-error - test override
|
||||
global.confirm = vi.fn(() => true);
|
||||
vi.stubGlobal('confirm', vi.fn(() => true));
|
||||
});
|
||||
|
||||
it('should render page title', () => {
|
||||
@@ -128,4 +127,3 @@ describe('EndpointsPage', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// ========================================
|
||||
// Application settings and configuration with CLI tools management
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import {
|
||||
Settings,
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
Calendar,
|
||||
File,
|
||||
ArrowUpCircle,
|
||||
Save,
|
||||
} from 'lucide-react';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
@@ -53,6 +54,21 @@ import {
|
||||
useUpgradeCcwInstallation,
|
||||
} from '@/hooks/useSystemSettings';
|
||||
|
||||
// ========== Tool Config File Helpers ==========
|
||||
|
||||
/** Tools that use .env file for environment variables */
|
||||
const ENV_FILE_TOOLS = new Set(['gemini', 'qwen', 'opencode']);
|
||||
/** Tools that use --settings for Claude CLI settings file */
|
||||
const SETTINGS_FILE_TOOLS = new Set(['claude']);
|
||||
/** Tools that don't need any config file */
|
||||
const NO_CONFIG_FILE_TOOLS = new Set(['codex']);
|
||||
|
||||
function getConfigFileType(toolId: string): 'envFile' | 'settingsFile' | 'none' {
|
||||
if (ENV_FILE_TOOLS.has(toolId)) return 'envFile';
|
||||
if (SETTINGS_FILE_TOOLS.has(toolId)) return 'settingsFile';
|
||||
return 'none';
|
||||
}
|
||||
|
||||
// ========== CLI Tool Card Component ==========
|
||||
|
||||
interface CliToolCardProps {
|
||||
@@ -61,13 +77,16 @@ interface CliToolCardProps {
|
||||
isDefault: boolean;
|
||||
isExpanded: boolean;
|
||||
toolAvailable?: boolean;
|
||||
isSaving?: boolean;
|
||||
onToggleExpand: () => void;
|
||||
onToggleEnabled: () => void;
|
||||
onSetDefault: () => void;
|
||||
onUpdateModel: (field: 'primaryModel' | 'secondaryModel', value: string) => void;
|
||||
onUpdateTags: (tags: string[]) => void;
|
||||
onUpdateAvailableModels: (models: string[]) => void;
|
||||
onUpdateEnvFile: (envFile: string | undefined) => void;
|
||||
onUpdateSettingsFile: (settingsFile: string | undefined) => void;
|
||||
onSaveToBackend: () => void;
|
||||
}
|
||||
|
||||
function CliToolCard({
|
||||
@@ -76,13 +95,16 @@ function CliToolCard({
|
||||
isDefault,
|
||||
isExpanded,
|
||||
toolAvailable,
|
||||
isSaving,
|
||||
onToggleExpand,
|
||||
onToggleEnabled,
|
||||
onSetDefault,
|
||||
onUpdateModel,
|
||||
onUpdateTags,
|
||||
onUpdateAvailableModels,
|
||||
onUpdateEnvFile,
|
||||
onUpdateSettingsFile,
|
||||
onSaveToBackend,
|
||||
}: CliToolCardProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
@@ -123,6 +145,8 @@ function CliToolCard({
|
||||
// Predefined tags
|
||||
const predefinedTags = ['分析', 'Debug', 'implementation', 'refactoring', 'testing'];
|
||||
|
||||
const configFileType = getConfigFileType(toolId);
|
||||
|
||||
return (
|
||||
<Card className={cn('overflow-hidden', !config.enabled && 'opacity-60')}>
|
||||
{/* Header */}
|
||||
@@ -350,26 +374,59 @@ function CliToolCard({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Settings File */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground">
|
||||
{formatMessage({ id: 'apiSettings.cliSettings.settingsFile' })}
|
||||
</label>
|
||||
<Input
|
||||
value={config.settingsFile || ''}
|
||||
onChange={(e) => onUpdateSettingsFile(e.target.value || undefined)}
|
||||
placeholder={formatMessage({ id: 'apiSettings.cliSettings.settingsFilePlaceholder' })}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatMessage({ id: 'apiSettings.cliSettings.settingsFileHint' })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!isDefault && config.enabled && (
|
||||
<Button variant="outline" size="sm" onClick={onSetDefault}>
|
||||
{formatMessage({ id: 'settings.cliTools.setDefault' })}
|
||||
</Button>
|
||||
{/* Env File - for gemini/qwen/opencode */}
|
||||
{configFileType === 'envFile' && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground">
|
||||
{formatMessage({ id: 'settings.cliTools.envFile' })}
|
||||
</label>
|
||||
<Input
|
||||
value={config.envFile || ''}
|
||||
onChange={(e) => onUpdateEnvFile(e.target.value || undefined)}
|
||||
placeholder={formatMessage({ id: 'settings.cliTools.envFilePlaceholder' })}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatMessage({ id: 'settings.cliTools.envFileHint' })}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Settings File - for claude only */}
|
||||
{configFileType === 'settingsFile' && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground">
|
||||
{formatMessage({ id: 'apiSettings.cliSettings.settingsFile' })}
|
||||
</label>
|
||||
<Input
|
||||
value={config.settingsFile || ''}
|
||||
onChange={(e) => onUpdateSettingsFile(e.target.value || undefined)}
|
||||
placeholder={formatMessage({ id: 'apiSettings.cliSettings.settingsFilePlaceholder' })}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatMessage({ id: 'apiSettings.cliSettings.settingsFileHint' })}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex items-center gap-2">
|
||||
{!isDefault && config.enabled && (
|
||||
<Button variant="outline" size="sm" onClick={onSetDefault}>
|
||||
{formatMessage({ id: 'settings.cliTools.setDefault' })}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={onSaveToBackend}
|
||||
disabled={isSaving}
|
||||
>
|
||||
<Save className="w-4 h-4 mr-1" />
|
||||
{isSaving
|
||||
? formatMessage({ id: 'settings.cliTools.saving' })
|
||||
: formatMessage({ id: 'settings.cliTools.saveToConfig' })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
@@ -666,13 +723,16 @@ interface CliToolsWithStatusProps {
|
||||
cliTools: Record<string, CliToolConfig>;
|
||||
defaultCliTool: string;
|
||||
expandedTools: Set<string>;
|
||||
savingTools: Set<string>;
|
||||
onToggleExpand: (toolId: string) => void;
|
||||
onToggleEnabled: (toolId: string) => void;
|
||||
onSetDefault: (toolId: string) => void;
|
||||
onUpdateModel: (toolId: string, field: 'primaryModel' | 'secondaryModel', value: string) => void;
|
||||
onUpdateTags: (toolId: string, tags: string[]) => void;
|
||||
onUpdateAvailableModels: (toolId: string, models: string[]) => void;
|
||||
onUpdateEnvFile: (toolId: string, envFile: string | undefined) => void;
|
||||
onUpdateSettingsFile: (toolId: string, settingsFile: string | undefined) => void;
|
||||
onSaveToBackend: (toolId: string) => void;
|
||||
formatMessage: ReturnType<typeof useIntl>['formatMessage'];
|
||||
}
|
||||
|
||||
@@ -680,13 +740,16 @@ function CliToolsWithStatus({
|
||||
cliTools,
|
||||
defaultCliTool,
|
||||
expandedTools,
|
||||
savingTools,
|
||||
onToggleExpand,
|
||||
onToggleEnabled,
|
||||
onSetDefault,
|
||||
onUpdateModel,
|
||||
onUpdateTags,
|
||||
onUpdateAvailableModels,
|
||||
onUpdateEnvFile,
|
||||
onUpdateSettingsFile,
|
||||
onSaveToBackend,
|
||||
formatMessage,
|
||||
}: CliToolsWithStatusProps) {
|
||||
const { data: toolStatus } = useCliToolStatus();
|
||||
@@ -707,13 +770,16 @@ function CliToolsWithStatus({
|
||||
isDefault={toolId === defaultCliTool}
|
||||
isExpanded={expandedTools.has(toolId)}
|
||||
toolAvailable={status?.available}
|
||||
isSaving={savingTools.has(toolId)}
|
||||
onToggleExpand={() => onToggleExpand(toolId)}
|
||||
onToggleEnabled={() => onToggleEnabled(toolId)}
|
||||
onSetDefault={() => onSetDefault(toolId)}
|
||||
onUpdateModel={(field, value) => onUpdateModel(toolId, field, value)}
|
||||
onUpdateTags={(tags) => onUpdateTags(toolId, tags)}
|
||||
onUpdateAvailableModels={(models) => onUpdateAvailableModels(toolId, models)}
|
||||
onUpdateEnvFile={(envFile) => onUpdateEnvFile(toolId, envFile)}
|
||||
onUpdateSettingsFile={(settingsFile) => onUpdateSettingsFile(toolId, settingsFile)}
|
||||
onSaveToBackend={() => onSaveToBackend(toolId)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -733,6 +799,7 @@ export function SettingsPage() {
|
||||
const { updateCliTool, setDefaultCliTool, setUserPreferences, resetUserPreferences } = useConfigStore();
|
||||
|
||||
const [expandedTools, setExpandedTools] = useState<Set<string>>(new Set());
|
||||
const [savingTools, setSavingTools] = useState<Set<string>>(new Set());
|
||||
|
||||
const toggleToolExpand = (toolId: string) => {
|
||||
setExpandedTools((prev) => {
|
||||
@@ -766,10 +833,68 @@ export function SettingsPage() {
|
||||
updateCliTool(toolId, { availableModels });
|
||||
};
|
||||
|
||||
const handleUpdateEnvFile = (toolId: string, envFile: string | undefined) => {
|
||||
updateCliTool(toolId, { envFile });
|
||||
};
|
||||
|
||||
const handleUpdateSettingsFile = (toolId: string, settingsFile: string | undefined) => {
|
||||
updateCliTool(toolId, { settingsFile });
|
||||
};
|
||||
|
||||
// Save tool config to backend (~/.claude/cli-tools.json)
|
||||
const handleSaveToBackend = useCallback(async (toolId: string) => {
|
||||
const config = cliTools[toolId];
|
||||
if (!config) return;
|
||||
|
||||
setSavingTools((prev) => new Set(prev).add(toolId));
|
||||
try {
|
||||
const body: Record<string, unknown> = {
|
||||
enabled: config.enabled,
|
||||
primaryModel: config.primaryModel,
|
||||
secondaryModel: config.secondaryModel,
|
||||
tags: config.tags,
|
||||
availableModels: config.availableModels,
|
||||
};
|
||||
|
||||
// Only include the relevant config file field
|
||||
const configFileType = getConfigFileType(toolId);
|
||||
if (configFileType === 'envFile') {
|
||||
body.envFile = config.envFile || null;
|
||||
} else if (configFileType === 'settingsFile') {
|
||||
body.settingsFile = config.settingsFile || null;
|
||||
}
|
||||
|
||||
const res = await fetch(`/api/cli/config/${toolId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
// Show success notification via a brief visual indicator
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'fixed bottom-4 right-4 z-50 bg-green-600 text-white px-4 py-2 rounded-lg shadow-lg text-sm animate-in fade-in slide-in-from-bottom-2';
|
||||
toast.textContent = formatMessage({ id: 'settings.cliTools.configSaved' });
|
||||
document.body.appendChild(toast);
|
||||
setTimeout(() => toast.remove(), 3000);
|
||||
} catch {
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'fixed bottom-4 right-4 z-50 bg-red-600 text-white px-4 py-2 rounded-lg shadow-lg text-sm animate-in fade-in slide-in-from-bottom-2';
|
||||
toast.textContent = formatMessage({ id: 'settings.cliTools.configSaveError' });
|
||||
document.body.appendChild(toast);
|
||||
setTimeout(() => toast.remove(), 4000);
|
||||
} finally {
|
||||
setSavingTools((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(toolId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, [cliTools, formatMessage]);
|
||||
|
||||
const handlePreferenceChange = (key: keyof UserPreferences, value: unknown) => {
|
||||
setUserPreferences({ [key]: value });
|
||||
};
|
||||
@@ -859,13 +984,16 @@ export function SettingsPage() {
|
||||
cliTools={cliTools}
|
||||
defaultCliTool={defaultCliTool}
|
||||
expandedTools={expandedTools}
|
||||
savingTools={savingTools}
|
||||
onToggleExpand={toggleToolExpand}
|
||||
onToggleEnabled={handleToggleToolEnabled}
|
||||
onSetDefault={handleSetDefaultTool}
|
||||
onUpdateModel={handleUpdateModel}
|
||||
onUpdateTags={handleUpdateTags}
|
||||
onUpdateAvailableModels={handleUpdateAvailableModels}
|
||||
onUpdateEnvFile={handleUpdateEnvFile}
|
||||
onUpdateSettingsFile={handleUpdateSettingsFile}
|
||||
onSaveToBackend={handleSaveToBackend}
|
||||
formatMessage={formatMessage}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
@@ -321,6 +321,9 @@ export interface CliToolConfig {
|
||||
secondaryModel: string;
|
||||
tags: string[];
|
||||
type: 'builtin' | 'cli-wrapper' | 'api-endpoint';
|
||||
/** Path to .env file for environment variables (gemini/qwen/opencode) */
|
||||
envFile?: string;
|
||||
/** Path to Claude CLI settings.json, passed via --settings (claude only) */
|
||||
settingsFile?: string;
|
||||
availableModels?: string[];
|
||||
}
|
||||
|
||||
@@ -320,7 +320,7 @@ export async function handleCliRoutes(ctx: RouteContext): Promise<boolean> {
|
||||
if (req.method === 'PUT') {
|
||||
handlePostRequest(req, res, async (body: unknown) => {
|
||||
try {
|
||||
const updates = body as { enabled?: boolean; primaryModel?: string; secondaryModel?: string; availableModels?: string[]; tags?: string[]; envFile?: string | null };
|
||||
const updates = body as { enabled?: boolean; primaryModel?: string; secondaryModel?: string; availableModels?: string[]; tags?: string[]; envFile?: string | null; settingsFile?: string | null };
|
||||
const updated = updateToolConfig(initialPath, tool, updates);
|
||||
|
||||
// Broadcast config updated event
|
||||
|
||||
Reference in New Issue
Block a user