mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-03-10 16:37:40 +00:00
Compare commits
39 Commits
v1.24.0
...
refactor/s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af28e73b24 | ||
|
|
786b00ad87 | ||
|
|
3c10355009 | ||
|
|
4cc34358ab | ||
|
|
783ad0c584 | ||
|
|
6e55bd2d75 | ||
|
|
28e9bf7eb0 | ||
|
|
65efda3278 | ||
|
|
525c9f72ca | ||
|
|
7802eb0cac | ||
|
|
a8b67e8ee7 | ||
|
|
3b231c64f2 | ||
|
|
9d8b759250 | ||
|
|
11d72a3318 | ||
|
|
d118f9694c | ||
|
|
b5f7d2eada | ||
|
|
ff176a9368 | ||
|
|
aad99fd935 | ||
|
|
b3ace59728 | ||
|
|
06d8a4f2d1 | ||
|
|
cd122913ce | ||
|
|
b18e0de2f3 | ||
|
|
521ad8ab41 | ||
|
|
d0ec14fb21 | ||
|
|
f68600bb76 | ||
|
|
6f686b9da8 | ||
|
|
5ed4798f58 | ||
|
|
f4686c0ed6 | ||
|
|
2492eb1052 | ||
|
|
86ab1291ff | ||
|
|
2699f0dc4b | ||
|
|
338eada187 | ||
|
|
f2e195bd08 | ||
|
|
040c300c29 | ||
|
|
6f50c54769 | ||
|
|
b9bac8a5e5 | ||
|
|
6e03964b8b | ||
|
|
5b7995751e | ||
|
|
a78bff874b |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"git": {
|
||||
"commitMessage": "chore(release): v${version}",
|
||||
"commitMessage": "Release ${version}",
|
||||
"tagName": "v${version}",
|
||||
"requireBranch": "main",
|
||||
"requireCleanWorkingDir": true
|
||||
|
||||
35
CHANGELOG.md
35
CHANGELOG.md
@@ -3,41 +3,6 @@
|
||||
All notable changes to CloudCLI UI will be documented in this file.
|
||||
|
||||
|
||||
## [1.24.0](https://github.com/siteboon/claudecodeui/compare/v1.23.2...v1.24.0) (2026-03-09)
|
||||
|
||||
### New Features
|
||||
|
||||
* add full-text search across conversations ([#482](https://github.com/siteboon/claudecodeui/issues/482)) ([3950c0e](https://github.com/siteboon/claudecodeui/commit/3950c0e47f41e93227af31494690818d45c8bc7a))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **git:** prevent shell injection in git routes ([86c33c1](https://github.com/siteboon/claudecodeui/commit/86c33c1c0cb34176725a38f46960213714fc3e04))
|
||||
* replace getDatabase with better-sqlite3 db in getGithubTokenById ([#501](https://github.com/siteboon/claudecodeui/issues/501)) ([cb4fd79](https://github.com/siteboon/claudecodeui/commit/cb4fd795c938b1cc86d47f401973bfccdd68fdee))
|
||||
|
||||
## [1.23.2](https://github.com/siteboon/claudecodeui/compare/v1.22.1...v1.23.2) (2026-03-06)
|
||||
|
||||
### New Features
|
||||
|
||||
* add clickable overlay buttons for CLI prompts in Shell terminal ([#480](https://github.com/siteboon/claudecodeui/issues/480)) ([2444209](https://github.com/siteboon/claudecodeui/commit/2444209723701dda2b881cea2501b239e64e51c1)), closes [#427](https://github.com/siteboon/claudecodeui/issues/427)
|
||||
* add terminal shortcuts panel for mobile ([#411](https://github.com/siteboon/claudecodeui/issues/411)) ([b0a3fdf](https://github.com/siteboon/claudecodeui/commit/b0a3fdf95ffdb961261194d10400267251e42f17))
|
||||
* implement session rename with SQLite storage ([#413](https://github.com/siteboon/claudecodeui/issues/413)) ([198e3da](https://github.com/siteboon/claudecodeui/commit/198e3da89b353780f53a91888384da9118995e81)), closes [#72](https://github.com/siteboon/claudecodeui/issues/72) [#358](https://github.com/siteboon/claudecodeui/issues/358)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **chat:** finalize terminal lifecycle to prevent stuck processing/thinking UI ([#483](https://github.com/siteboon/claudecodeui/issues/483)) ([0590c5c](https://github.com/siteboon/claudecodeui/commit/0590c5c178f4791e2b039d525ecca4d220c3dcae))
|
||||
* **codex-history:** prevent AGENTS.md/internal prompt leakage when reloading Codex sessions ([#488](https://github.com/siteboon/claudecodeui/issues/488)) ([64a96b2](https://github.com/siteboon/claudecodeui/commit/64a96b24f853acb802f700810b302f0f5cf00898))
|
||||
* preserve pending permission requests across WebSocket reconnections ([#462](https://github.com/siteboon/claudecodeui/issues/462)) ([4ee88f0](https://github.com/siteboon/claudecodeui/commit/4ee88f0eb0c648b54b05f006c6796fb7b09b0fae))
|
||||
* prevent React 18 batching from losing messages during session sync ([#461](https://github.com/siteboon/claudecodeui/issues/461)) ([688d734](https://github.com/siteboon/claudecodeui/commit/688d73477a50773e43c85addc96212aa6290aea5))
|
||||
* release it script ([dcea8a3](https://github.com/siteboon/claudecodeui/commit/dcea8a329c7d68437e1e72c8c766cf33c74637e9))
|
||||
|
||||
### Styling
|
||||
|
||||
* improve UI for processing banner ([#477](https://github.com/siteboon/claudecodeui/issues/477)) ([2320e1d](https://github.com/siteboon/claudecodeui/commit/2320e1d74b59c65b5b7fc4fa8b05fd9208f4898c))
|
||||
|
||||
### Maintenance
|
||||
|
||||
* remove logging of received WebSocket messages in production ([#487](https://github.com/siteboon/claudecodeui/issues/487)) ([9193feb](https://github.com/siteboon/claudecodeui/commit/9193feb6dc83041f3c365204648a88468bdc001b))
|
||||
|
||||
## [1.22.0](https://github.com/siteboon/claudecodeui/compare/v1.21.0...v1.22.0) (2026-03-03)
|
||||
|
||||
### New Features
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@siteboon/claude-code-ui",
|
||||
"version": "1.24.0",
|
||||
"version": "1.22.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@siteboon/claude-code-ui",
|
||||
"version": "1.24.0",
|
||||
"version": "1.22.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "GPL-3.0",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@siteboon/claude-code-ui",
|
||||
"version": "1.24.0",
|
||||
"version": "1.22.0",
|
||||
"description": "A web-based UI for Claude Code CLI",
|
||||
"type": "module",
|
||||
"main": "server/index.js",
|
||||
|
||||
Submodule plugins/starter deleted from bfa6332810
@@ -44,7 +44,7 @@ import pty from 'node-pty';
|
||||
import fetch from 'node-fetch';
|
||||
import mime from 'mime-types';
|
||||
|
||||
import { getProjects, getSessions, getSessionMessages, renameProject, deleteSession, deleteProject, addProjectManually, extractProjectDirectory, clearProjectDirectoryCache, searchConversations } from './projects.js';
|
||||
import { getProjects, getSessions, getSessionMessages, renameProject, deleteSession, deleteProject, addProjectManually, extractProjectDirectory, clearProjectDirectoryCache } from './projects.js';
|
||||
import { queryClaudeSDK, abortClaudeSDKSession, isClaudeSDKSessionActive, getActiveClaudeSDKSessions, resolveToolApproval, getPendingApprovalsForSession, reconnectSessionWriter } from './claude-sdk.js';
|
||||
import { spawnCursor, abortCursorSession, isCursorSessionActive, getActiveCursorSessions } from './cursor-cli.js';
|
||||
import { queryCodex, abortCodexSession, isCodexSessionActive, getActiveCodexSessions } from './openai-codex.js';
|
||||
@@ -608,51 +608,6 @@ app.post('/api/projects/create', authenticateToken, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Search conversations content (SSE streaming)
|
||||
app.get('/api/search/conversations', authenticateToken, async (req, res) => {
|
||||
const query = typeof req.query.q === 'string' ? req.query.q.trim() : '';
|
||||
const parsedLimit = Number.parseInt(String(req.query.limit), 10);
|
||||
const limit = Number.isNaN(parsedLimit) ? 50 : Math.max(1, Math.min(parsedLimit, 100));
|
||||
|
||||
if (query.length < 2) {
|
||||
return res.status(400).json({ error: 'Query must be at least 2 characters' });
|
||||
}
|
||||
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
'X-Accel-Buffering': 'no',
|
||||
});
|
||||
|
||||
let closed = false;
|
||||
const abortController = new AbortController();
|
||||
req.on('close', () => { closed = true; abortController.abort(); });
|
||||
|
||||
try {
|
||||
await searchConversations(query, limit, ({ projectResult, totalMatches, scannedProjects, totalProjects }) => {
|
||||
if (closed) return;
|
||||
if (projectResult) {
|
||||
res.write(`event: result\ndata: ${JSON.stringify({ projectResult, totalMatches, scannedProjects, totalProjects })}\n\n`);
|
||||
} else {
|
||||
res.write(`event: progress\ndata: ${JSON.stringify({ totalMatches, scannedProjects, totalProjects })}\n\n`);
|
||||
}
|
||||
}, abortController.signal);
|
||||
if (!closed) {
|
||||
res.write(`event: done\ndata: {}\n\n`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error searching conversations:', error);
|
||||
if (!closed) {
|
||||
res.write(`event: error\ndata: ${JSON.stringify({ error: 'Search failed' })}\n\n`);
|
||||
}
|
||||
} finally {
|
||||
if (!closed) {
|
||||
res.end();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const expandWorkspacePath = (inputPath) => {
|
||||
if (!inputPath) return inputPath;
|
||||
if (inputPath === '~') {
|
||||
|
||||
@@ -481,13 +481,9 @@ async function getProjects(progressCallback = null) {
|
||||
}
|
||||
applyCustomSessionNames(project.codexSessions, 'codex');
|
||||
|
||||
// Also fetch Gemini sessions for this project (UI + CLI)
|
||||
// Also fetch Gemini sessions for this project
|
||||
try {
|
||||
const uiSessions = sessionManager.getProjectSessions(actualProjectDir) || [];
|
||||
const cliSessions = await getGeminiCliSessions(actualProjectDir);
|
||||
const uiIds = new Set(uiSessions.map(s => s.id));
|
||||
const mergedGemini = [...uiSessions, ...cliSessions.filter(s => !uiIds.has(s.id))];
|
||||
project.geminiSessions = mergedGemini;
|
||||
project.geminiSessions = sessionManager.getProjectSessions(actualProjectDir) || [];
|
||||
} catch (e) {
|
||||
console.warn(`Could not load Gemini sessions for project ${entry.name}:`, e.message);
|
||||
project.geminiSessions = [];
|
||||
@@ -588,12 +584,9 @@ async function getProjects(progressCallback = null) {
|
||||
}
|
||||
applyCustomSessionNames(project.codexSessions, 'codex');
|
||||
|
||||
// Try to fetch Gemini sessions for manual projects too (UI + CLI)
|
||||
// Try to fetch Gemini sessions for manual projects too
|
||||
try {
|
||||
const uiSessions = sessionManager.getProjectSessions(actualProjectDir) || [];
|
||||
const cliSessions = await getGeminiCliSessions(actualProjectDir);
|
||||
const uiIds = new Set(uiSessions.map(s => s.id));
|
||||
project.geminiSessions = [...uiSessions, ...cliSessions.filter(s => !uiIds.has(s.id))];
|
||||
project.geminiSessions = sessionManager.getProjectSessions(actualProjectDir) || [];
|
||||
} catch (e) {
|
||||
console.warn(`Could not load Gemini sessions for manual project ${projectName}:`, e.message);
|
||||
}
|
||||
@@ -1869,675 +1862,6 @@ async function deleteCodexSession(sessionId) {
|
||||
}
|
||||
}
|
||||
|
||||
async function searchConversations(query, limit = 50, onProjectResult = null, signal = null) {
|
||||
const safeQuery = typeof query === 'string' ? query.trim() : '';
|
||||
const safeLimit = Math.max(1, Math.min(Number.isFinite(limit) ? limit : 50, 200));
|
||||
const claudeDir = path.join(os.homedir(), '.claude', 'projects');
|
||||
const config = await loadProjectConfig();
|
||||
const results = [];
|
||||
let totalMatches = 0;
|
||||
const words = safeQuery.toLowerCase().split(/\s+/).filter(w => w.length > 0);
|
||||
if (words.length === 0) return { results: [], totalMatches: 0, query: safeQuery };
|
||||
|
||||
const isAborted = () => signal?.aborted === true;
|
||||
|
||||
const isSystemMessage = (textContent) => {
|
||||
return typeof textContent === 'string' && (
|
||||
textContent.startsWith('<command-name>') ||
|
||||
textContent.startsWith('<command-message>') ||
|
||||
textContent.startsWith('<command-args>') ||
|
||||
textContent.startsWith('<local-command-stdout>') ||
|
||||
textContent.startsWith('<system-reminder>') ||
|
||||
textContent.startsWith('Caveat:') ||
|
||||
textContent.startsWith('This session is being continued from a previous') ||
|
||||
textContent.startsWith('Invalid API key') ||
|
||||
textContent.includes('{"subtasks":') ||
|
||||
textContent.includes('CRITICAL: You MUST respond with ONLY a JSON') ||
|
||||
textContent === 'Warmup'
|
||||
);
|
||||
};
|
||||
|
||||
const extractText = (content) => {
|
||||
if (typeof content === 'string') return content;
|
||||
if (Array.isArray(content)) {
|
||||
return content
|
||||
.filter(part => part.type === 'text' && part.text)
|
||||
.map(part => part.text)
|
||||
.join(' ');
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
const escapeRegex = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const wordPatterns = words.map(w => new RegExp(`(?<!\\p{L})${escapeRegex(w)}(?!\\p{L})`, 'u'));
|
||||
const allWordsMatch = (textLower) => {
|
||||
return wordPatterns.every(p => p.test(textLower));
|
||||
};
|
||||
|
||||
const buildSnippet = (text, textLower, snippetLen = 150) => {
|
||||
let firstIndex = -1;
|
||||
let firstWordLen = 0;
|
||||
for (const w of words) {
|
||||
const re = new RegExp(`(?<!\\p{L})${escapeRegex(w)}(?!\\p{L})`, 'u');
|
||||
const m = re.exec(textLower);
|
||||
if (m && (firstIndex === -1 || m.index < firstIndex)) {
|
||||
firstIndex = m.index;
|
||||
firstWordLen = w.length;
|
||||
}
|
||||
}
|
||||
if (firstIndex === -1) firstIndex = 0;
|
||||
const halfLen = Math.floor(snippetLen / 2);
|
||||
let start = Math.max(0, firstIndex - halfLen);
|
||||
let end = Math.min(text.length, firstIndex + halfLen + firstWordLen);
|
||||
let snippet = text.slice(start, end).replace(/\n/g, ' ');
|
||||
const prefix = start > 0 ? '...' : '';
|
||||
const suffix = end < text.length ? '...' : '';
|
||||
snippet = prefix + snippet + suffix;
|
||||
const snippetLower = snippet.toLowerCase();
|
||||
const highlights = [];
|
||||
for (const word of words) {
|
||||
const re = new RegExp(`(?<!\\p{L})${escapeRegex(word)}(?!\\p{L})`, 'gu');
|
||||
let match;
|
||||
while ((match = re.exec(snippetLower)) !== null) {
|
||||
highlights.push({ start: match.index, end: match.index + word.length });
|
||||
}
|
||||
}
|
||||
highlights.sort((a, b) => a.start - b.start);
|
||||
const merged = [];
|
||||
for (const h of highlights) {
|
||||
const last = merged[merged.length - 1];
|
||||
if (last && h.start <= last.end) {
|
||||
last.end = Math.max(last.end, h.end);
|
||||
} else {
|
||||
merged.push({ ...h });
|
||||
}
|
||||
}
|
||||
return { snippet, highlights: merged };
|
||||
};
|
||||
|
||||
try {
|
||||
await fs.access(claudeDir);
|
||||
const entries = await fs.readdir(claudeDir, { withFileTypes: true });
|
||||
const projectDirs = entries.filter(e => e.isDirectory());
|
||||
let scannedProjects = 0;
|
||||
const totalProjects = projectDirs.length;
|
||||
|
||||
for (const projectEntry of projectDirs) {
|
||||
if (totalMatches >= safeLimit || isAborted()) break;
|
||||
|
||||
const projectName = projectEntry.name;
|
||||
const projectDir = path.join(claudeDir, projectName);
|
||||
const displayName = config[projectName]?.displayName
|
||||
|| await generateDisplayName(projectName);
|
||||
|
||||
let files;
|
||||
try {
|
||||
files = await fs.readdir(projectDir);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
const jsonlFiles = files.filter(
|
||||
file => file.endsWith('.jsonl') && !file.startsWith('agent-')
|
||||
);
|
||||
|
||||
const projectResult = {
|
||||
projectName,
|
||||
projectDisplayName: displayName,
|
||||
sessions: []
|
||||
};
|
||||
|
||||
for (const file of jsonlFiles) {
|
||||
if (totalMatches >= safeLimit || isAborted()) break;
|
||||
|
||||
const filePath = path.join(projectDir, file);
|
||||
const sessionMatches = new Map();
|
||||
const sessionSummaries = new Map();
|
||||
const pendingSummaries = new Map();
|
||||
const sessionLastMessages = new Map();
|
||||
let currentSessionId = null;
|
||||
|
||||
try {
|
||||
const fileStream = fsSync.createReadStream(filePath);
|
||||
const rl = readline.createInterface({
|
||||
input: fileStream,
|
||||
crlfDelay: Infinity
|
||||
});
|
||||
|
||||
for await (const line of rl) {
|
||||
if (totalMatches >= safeLimit || isAborted()) break;
|
||||
if (!line.trim()) continue;
|
||||
|
||||
let entry;
|
||||
try {
|
||||
entry = JSON.parse(line);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.sessionId) {
|
||||
currentSessionId = entry.sessionId;
|
||||
}
|
||||
if (entry.type === 'summary' && entry.summary) {
|
||||
const sid = entry.sessionId || currentSessionId;
|
||||
if (sid) {
|
||||
sessionSummaries.set(sid, entry.summary);
|
||||
} else if (entry.leafUuid) {
|
||||
pendingSummaries.set(entry.leafUuid, entry.summary);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply pending summary via parentUuid
|
||||
if (entry.parentUuid && currentSessionId && !sessionSummaries.has(currentSessionId)) {
|
||||
const pending = pendingSummaries.get(entry.parentUuid);
|
||||
if (pending) sessionSummaries.set(currentSessionId, pending);
|
||||
}
|
||||
|
||||
// Track last user/assistant message for fallback title
|
||||
if (entry.message?.content && currentSessionId && !entry.isApiErrorMessage) {
|
||||
const role = entry.message.role;
|
||||
if (role === 'user' || role === 'assistant') {
|
||||
const text = extractText(entry.message.content);
|
||||
if (text && !isSystemMessage(text)) {
|
||||
if (!sessionLastMessages.has(currentSessionId)) {
|
||||
sessionLastMessages.set(currentSessionId, {});
|
||||
}
|
||||
const msgs = sessionLastMessages.get(currentSessionId);
|
||||
if (role === 'user') msgs.user = text;
|
||||
else msgs.assistant = text;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!entry.message?.content) continue;
|
||||
if (entry.message.role !== 'user' && entry.message.role !== 'assistant') continue;
|
||||
if (entry.isApiErrorMessage) continue;
|
||||
|
||||
const text = extractText(entry.message.content);
|
||||
if (!text || isSystemMessage(text)) continue;
|
||||
|
||||
const textLower = text.toLowerCase();
|
||||
if (!allWordsMatch(textLower)) continue;
|
||||
|
||||
const sessionId = entry.sessionId || currentSessionId || file.replace('.jsonl', '');
|
||||
if (!sessionMatches.has(sessionId)) {
|
||||
sessionMatches.set(sessionId, []);
|
||||
}
|
||||
|
||||
const matches = sessionMatches.get(sessionId);
|
||||
if (matches.length < 2) {
|
||||
const { snippet, highlights } = buildSnippet(text, textLower);
|
||||
matches.push({
|
||||
role: entry.message.role,
|
||||
snippet,
|
||||
highlights,
|
||||
timestamp: entry.timestamp || null,
|
||||
provider: 'claude',
|
||||
messageUuid: entry.uuid || null
|
||||
});
|
||||
totalMatches++;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const [sessionId, matches] of sessionMatches) {
|
||||
projectResult.sessions.push({
|
||||
sessionId,
|
||||
provider: 'claude',
|
||||
sessionSummary: sessionSummaries.get(sessionId) || (() => {
|
||||
const msgs = sessionLastMessages.get(sessionId);
|
||||
const lastMsg = msgs?.user || msgs?.assistant;
|
||||
return lastMsg ? (lastMsg.length > 50 ? lastMsg.substring(0, 50) + '...' : lastMsg) : 'New Session';
|
||||
})(),
|
||||
matches
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Search Codex sessions for this project
|
||||
try {
|
||||
const actualProjectDir = await extractProjectDirectory(projectName);
|
||||
if (actualProjectDir && !isAborted() && totalMatches < safeLimit) {
|
||||
await searchCodexSessionsForProject(
|
||||
actualProjectDir, projectResult, words, allWordsMatch, extractText, isSystemMessage,
|
||||
buildSnippet, safeLimit, () => totalMatches, (n) => { totalMatches += n; }, isAborted
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Skip codex search errors
|
||||
}
|
||||
|
||||
// Search Gemini sessions for this project
|
||||
try {
|
||||
const actualProjectDir = await extractProjectDirectory(projectName);
|
||||
if (actualProjectDir && !isAborted() && totalMatches < safeLimit) {
|
||||
await searchGeminiSessionsForProject(
|
||||
actualProjectDir, projectResult, words, allWordsMatch,
|
||||
buildSnippet, safeLimit, () => totalMatches, (n) => { totalMatches += n; }
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Skip gemini search errors
|
||||
}
|
||||
|
||||
scannedProjects++;
|
||||
if (projectResult.sessions.length > 0) {
|
||||
results.push(projectResult);
|
||||
if (onProjectResult) {
|
||||
onProjectResult({ projectResult, totalMatches, scannedProjects, totalProjects });
|
||||
}
|
||||
} else if (onProjectResult && scannedProjects % 10 === 0) {
|
||||
onProjectResult({ projectResult: null, totalMatches, scannedProjects, totalProjects });
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// claudeDir doesn't exist
|
||||
}
|
||||
|
||||
return { results, totalMatches, query: safeQuery };
|
||||
}
|
||||
|
||||
async function searchCodexSessionsForProject(
|
||||
projectPath, projectResult, words, allWordsMatch, extractText, isSystemMessage,
|
||||
buildSnippet, limit, getTotalMatches, addMatches, isAborted
|
||||
) {
|
||||
const normalizedProjectPath = normalizeComparablePath(projectPath);
|
||||
if (!normalizedProjectPath) return;
|
||||
const codexSessionsDir = path.join(os.homedir(), '.codex', 'sessions');
|
||||
try {
|
||||
await fs.access(codexSessionsDir);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const jsonlFiles = await findCodexJsonlFiles(codexSessionsDir);
|
||||
|
||||
for (const filePath of jsonlFiles) {
|
||||
if (getTotalMatches() >= limit || isAborted()) break;
|
||||
|
||||
try {
|
||||
const fileStream = fsSync.createReadStream(filePath);
|
||||
const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity });
|
||||
|
||||
// First pass: read session_meta to check project path match
|
||||
let sessionMeta = null;
|
||||
for await (const line of rl) {
|
||||
if (!line.trim()) continue;
|
||||
try {
|
||||
const entry = JSON.parse(line);
|
||||
if (entry.type === 'session_meta' && entry.payload) {
|
||||
sessionMeta = entry.payload;
|
||||
break;
|
||||
}
|
||||
} catch { continue; }
|
||||
}
|
||||
|
||||
// Skip sessions that don't belong to this project
|
||||
if (!sessionMeta) continue;
|
||||
const sessionProjectPath = normalizeComparablePath(sessionMeta.cwd);
|
||||
if (sessionProjectPath !== normalizedProjectPath) continue;
|
||||
|
||||
// Second pass: re-read file to find matching messages
|
||||
const fileStream2 = fsSync.createReadStream(filePath);
|
||||
const rl2 = readline.createInterface({ input: fileStream2, crlfDelay: Infinity });
|
||||
let lastUserMessage = null;
|
||||
const matches = [];
|
||||
|
||||
for await (const line of rl2) {
|
||||
if (getTotalMatches() >= limit || isAborted()) break;
|
||||
if (!line.trim()) continue;
|
||||
|
||||
let entry;
|
||||
try { entry = JSON.parse(line); } catch { continue; }
|
||||
|
||||
let text = null;
|
||||
let role = null;
|
||||
|
||||
if (entry.type === 'event_msg' && entry.payload?.type === 'user_message' && entry.payload.message) {
|
||||
text = entry.payload.message;
|
||||
role = 'user';
|
||||
lastUserMessage = text;
|
||||
} else if (entry.type === 'response_item' && entry.payload?.type === 'message') {
|
||||
const contentParts = entry.payload.content || [];
|
||||
if (entry.payload.role === 'user') {
|
||||
text = contentParts
|
||||
.filter(p => p.type === 'input_text' && p.text)
|
||||
.map(p => p.text)
|
||||
.join(' ');
|
||||
role = 'user';
|
||||
if (text) lastUserMessage = text;
|
||||
} else if (entry.payload.role === 'assistant') {
|
||||
text = contentParts
|
||||
.filter(p => p.type === 'output_text' && p.text)
|
||||
.map(p => p.text)
|
||||
.join(' ');
|
||||
role = 'assistant';
|
||||
}
|
||||
}
|
||||
|
||||
if (!text || !role) continue;
|
||||
const textLower = text.toLowerCase();
|
||||
if (!allWordsMatch(textLower)) continue;
|
||||
|
||||
if (matches.length < 2) {
|
||||
const { snippet, highlights } = buildSnippet(text, textLower);
|
||||
matches.push({ role, snippet, highlights, timestamp: entry.timestamp || null, provider: 'codex' });
|
||||
addMatches(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (matches.length > 0) {
|
||||
projectResult.sessions.push({
|
||||
sessionId: sessionMeta.id,
|
||||
provider: 'codex',
|
||||
sessionSummary: lastUserMessage
|
||||
? (lastUserMessage.length > 50 ? lastUserMessage.substring(0, 50) + '...' : lastUserMessage)
|
||||
: 'Codex Session',
|
||||
matches
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function searchGeminiSessionsForProject(
|
||||
projectPath, projectResult, words, allWordsMatch,
|
||||
buildSnippet, limit, getTotalMatches, addMatches
|
||||
) {
|
||||
// 1) Search in-memory sessions (created via UI)
|
||||
for (const [sessionId, session] of sessionManager.sessions) {
|
||||
if (getTotalMatches() >= limit) break;
|
||||
if (session.projectPath !== projectPath) continue;
|
||||
|
||||
const matches = [];
|
||||
for (const msg of session.messages) {
|
||||
if (getTotalMatches() >= limit) break;
|
||||
if (msg.role !== 'user' && msg.role !== 'assistant') continue;
|
||||
|
||||
const text = typeof msg.content === 'string' ? msg.content
|
||||
: Array.isArray(msg.content) ? msg.content.filter(p => p.type === 'text').map(p => p.text).join(' ')
|
||||
: '';
|
||||
if (!text) continue;
|
||||
|
||||
const textLower = text.toLowerCase();
|
||||
if (!allWordsMatch(textLower)) continue;
|
||||
|
||||
if (matches.length < 2) {
|
||||
const { snippet, highlights } = buildSnippet(text, textLower);
|
||||
matches.push({
|
||||
role: msg.role, snippet, highlights,
|
||||
timestamp: msg.timestamp ? msg.timestamp.toISOString() : null,
|
||||
provider: 'gemini'
|
||||
});
|
||||
addMatches(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (matches.length > 0) {
|
||||
const firstUserMsg = session.messages.find(m => m.role === 'user');
|
||||
const summary = firstUserMsg?.content
|
||||
? (typeof firstUserMsg.content === 'string'
|
||||
? (firstUserMsg.content.length > 50 ? firstUserMsg.content.substring(0, 50) + '...' : firstUserMsg.content)
|
||||
: 'Gemini Session')
|
||||
: 'Gemini Session';
|
||||
|
||||
projectResult.sessions.push({
|
||||
sessionId,
|
||||
provider: 'gemini',
|
||||
sessionSummary: summary,
|
||||
matches
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Search Gemini CLI sessions on disk (~/.gemini/tmp/<project>/chats/*.json)
|
||||
const normalizedProjectPath = normalizeComparablePath(projectPath);
|
||||
if (!normalizedProjectPath) return;
|
||||
|
||||
const geminiTmpDir = path.join(os.homedir(), '.gemini', 'tmp');
|
||||
try {
|
||||
await fs.access(geminiTmpDir);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const trackedSessionIds = new Set();
|
||||
for (const [sid] of sessionManager.sessions) {
|
||||
trackedSessionIds.add(sid);
|
||||
}
|
||||
|
||||
let projectDirs;
|
||||
try {
|
||||
projectDirs = await fs.readdir(geminiTmpDir);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const projectDir of projectDirs) {
|
||||
if (getTotalMatches() >= limit) break;
|
||||
|
||||
const projectRootFile = path.join(geminiTmpDir, projectDir, '.project_root');
|
||||
let projectRoot;
|
||||
try {
|
||||
projectRoot = (await fs.readFile(projectRootFile, 'utf8')).trim();
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (normalizeComparablePath(projectRoot) !== normalizedProjectPath) continue;
|
||||
|
||||
const chatsDir = path.join(geminiTmpDir, projectDir, 'chats');
|
||||
let chatFiles;
|
||||
try {
|
||||
chatFiles = await fs.readdir(chatsDir);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const chatFile of chatFiles) {
|
||||
if (getTotalMatches() >= limit) break;
|
||||
if (!chatFile.endsWith('.json')) continue;
|
||||
|
||||
try {
|
||||
const filePath = path.join(chatsDir, chatFile);
|
||||
const data = await fs.readFile(filePath, 'utf8');
|
||||
const session = JSON.parse(data);
|
||||
if (!session.messages || !Array.isArray(session.messages)) continue;
|
||||
|
||||
const cliSessionId = session.sessionId || chatFile.replace('.json', '');
|
||||
if (trackedSessionIds.has(cliSessionId)) continue;
|
||||
|
||||
const matches = [];
|
||||
let firstUserText = null;
|
||||
|
||||
for (const msg of session.messages) {
|
||||
if (getTotalMatches() >= limit) break;
|
||||
|
||||
const role = msg.type === 'user' ? 'user'
|
||||
: (msg.type === 'gemini' || msg.type === 'assistant') ? 'assistant'
|
||||
: null;
|
||||
if (!role) continue;
|
||||
|
||||
let text = '';
|
||||
if (typeof msg.content === 'string') {
|
||||
text = msg.content;
|
||||
} else if (Array.isArray(msg.content)) {
|
||||
text = msg.content
|
||||
.filter(p => p.text)
|
||||
.map(p => p.text)
|
||||
.join(' ');
|
||||
}
|
||||
if (!text) continue;
|
||||
|
||||
if (role === 'user' && !firstUserText) firstUserText = text;
|
||||
|
||||
const textLower = text.toLowerCase();
|
||||
if (!allWordsMatch(textLower)) continue;
|
||||
|
||||
if (matches.length < 2) {
|
||||
const { snippet, highlights } = buildSnippet(text, textLower);
|
||||
matches.push({
|
||||
role, snippet, highlights,
|
||||
timestamp: msg.timestamp || null,
|
||||
provider: 'gemini'
|
||||
});
|
||||
addMatches(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (matches.length > 0) {
|
||||
const summary = firstUserText
|
||||
? (firstUserText.length > 50 ? firstUserText.substring(0, 50) + '...' : firstUserText)
|
||||
: 'Gemini CLI Session';
|
||||
|
||||
projectResult.sessions.push({
|
||||
sessionId: cliSessionId,
|
||||
provider: 'gemini',
|
||||
sessionSummary: summary,
|
||||
matches
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function getGeminiCliSessions(projectPath) {
|
||||
const normalizedProjectPath = normalizeComparablePath(projectPath);
|
||||
if (!normalizedProjectPath) return [];
|
||||
|
||||
const geminiTmpDir = path.join(os.homedir(), '.gemini', 'tmp');
|
||||
try {
|
||||
await fs.access(geminiTmpDir);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const sessions = [];
|
||||
let projectDirs;
|
||||
try {
|
||||
projectDirs = await fs.readdir(geminiTmpDir);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
for (const projectDir of projectDirs) {
|
||||
const projectRootFile = path.join(geminiTmpDir, projectDir, '.project_root');
|
||||
let projectRoot;
|
||||
try {
|
||||
projectRoot = (await fs.readFile(projectRootFile, 'utf8')).trim();
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (normalizeComparablePath(projectRoot) !== normalizedProjectPath) continue;
|
||||
|
||||
const chatsDir = path.join(geminiTmpDir, projectDir, 'chats');
|
||||
let chatFiles;
|
||||
try {
|
||||
chatFiles = await fs.readdir(chatsDir);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const chatFile of chatFiles) {
|
||||
if (!chatFile.endsWith('.json')) continue;
|
||||
try {
|
||||
const filePath = path.join(chatsDir, chatFile);
|
||||
const data = await fs.readFile(filePath, 'utf8');
|
||||
const session = JSON.parse(data);
|
||||
if (!session.messages || !Array.isArray(session.messages)) continue;
|
||||
|
||||
const sessionId = session.sessionId || chatFile.replace('.json', '');
|
||||
const firstUserMsg = session.messages.find(m => m.type === 'user');
|
||||
let summary = 'Gemini CLI Session';
|
||||
if (firstUserMsg) {
|
||||
const text = Array.isArray(firstUserMsg.content)
|
||||
? firstUserMsg.content.filter(p => p.text).map(p => p.text).join(' ')
|
||||
: (typeof firstUserMsg.content === 'string' ? firstUserMsg.content : '');
|
||||
if (text) {
|
||||
summary = text.length > 50 ? text.substring(0, 50) + '...' : text;
|
||||
}
|
||||
}
|
||||
|
||||
sessions.push({
|
||||
id: sessionId,
|
||||
summary,
|
||||
messageCount: session.messages.length,
|
||||
lastActivity: session.lastUpdated || session.startTime || null,
|
||||
provider: 'gemini'
|
||||
});
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sessions.sort((a, b) =>
|
||||
new Date(b.lastActivity || 0) - new Date(a.lastActivity || 0)
|
||||
);
|
||||
}
|
||||
|
||||
async function getGeminiCliSessionMessages(sessionId) {
|
||||
const geminiTmpDir = path.join(os.homedir(), '.gemini', 'tmp');
|
||||
let projectDirs;
|
||||
try {
|
||||
projectDirs = await fs.readdir(geminiTmpDir);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
for (const projectDir of projectDirs) {
|
||||
const chatsDir = path.join(geminiTmpDir, projectDir, 'chats');
|
||||
let chatFiles;
|
||||
try {
|
||||
chatFiles = await fs.readdir(chatsDir);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const chatFile of chatFiles) {
|
||||
if (!chatFile.endsWith('.json')) continue;
|
||||
try {
|
||||
const filePath = path.join(chatsDir, chatFile);
|
||||
const data = await fs.readFile(filePath, 'utf8');
|
||||
const session = JSON.parse(data);
|
||||
const fileSessionId = session.sessionId || chatFile.replace('.json', '');
|
||||
if (fileSessionId !== sessionId) continue;
|
||||
|
||||
return (session.messages || []).map(msg => {
|
||||
const role = msg.type === 'user' ? 'user'
|
||||
: (msg.type === 'gemini' || msg.type === 'assistant') ? 'assistant'
|
||||
: msg.type;
|
||||
|
||||
let content = '';
|
||||
if (typeof msg.content === 'string') {
|
||||
content = msg.content;
|
||||
} else if (Array.isArray(msg.content)) {
|
||||
content = msg.content.filter(p => p.text).map(p => p.text).join('\n');
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'message',
|
||||
message: { role, content },
|
||||
timestamp: msg.timestamp || null
|
||||
};
|
||||
});
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
export {
|
||||
getProjects,
|
||||
getSessions,
|
||||
@@ -2554,8 +1878,5 @@ export {
|
||||
clearProjectDirectoryCache,
|
||||
getCodexSessions,
|
||||
getCodexSessionMessages,
|
||||
deleteCodexSession,
|
||||
getGeminiCliSessions,
|
||||
getGeminiCliSessionMessages,
|
||||
searchConversations
|
||||
deleteCodexSession
|
||||
};
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import express from 'express';
|
||||
import sessionManager from '../sessionManager.js';
|
||||
import { sessionNamesDb } from '../database/db.js';
|
||||
import { getGeminiCliSessionMessages } from '../projects.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -13,12 +12,7 @@ router.get('/sessions/:sessionId/messages', async (req, res) => {
|
||||
return res.status(400).json({ success: false, error: 'Invalid session ID format' });
|
||||
}
|
||||
|
||||
let messages = sessionManager.getSessionMessages(sessionId);
|
||||
|
||||
// Fallback to Gemini CLI sessions on disk
|
||||
if (messages.length === 0) {
|
||||
messages = await getGeminiCliSessionMessages(sessionId);
|
||||
}
|
||||
const messages = sessionManager.getSessionMessages(sessionId);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import express from 'express';
|
||||
import { spawn } from 'child_process';
|
||||
import { exec, spawn } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import path from 'path';
|
||||
import { promises as fs } from 'fs';
|
||||
import { extractProjectDirectory } from '../projects.js';
|
||||
@@ -7,6 +8,7 @@ import { queryClaudeSDK } from '../claude-sdk.js';
|
||||
import { spawnCursor } from '../cursor-cli.js';
|
||||
|
||||
const router = express.Router();
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
function spawnAsync(command, args, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -45,36 +47,6 @@ function spawnAsync(command, args, options = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
// Input validation helpers (defense-in-depth)
|
||||
function validateCommitRef(commit) {
|
||||
// Allow hex hashes, HEAD, HEAD~N, HEAD^N, tag names, branch names
|
||||
if (!/^[a-zA-Z0-9._~^{}@\/-]+$/.test(commit)) {
|
||||
throw new Error('Invalid commit reference');
|
||||
}
|
||||
return commit;
|
||||
}
|
||||
|
||||
function validateBranchName(branch) {
|
||||
if (!/^[a-zA-Z0-9._\/-]+$/.test(branch)) {
|
||||
throw new Error('Invalid branch name');
|
||||
}
|
||||
return branch;
|
||||
}
|
||||
|
||||
function validateFilePath(file) {
|
||||
if (!file || file.includes('\0')) {
|
||||
throw new Error('Invalid file path');
|
||||
}
|
||||
return file;
|
||||
}
|
||||
|
||||
function validateRemoteName(remote) {
|
||||
if (!/^[a-zA-Z0-9._-]+$/.test(remote)) {
|
||||
throw new Error('Invalid remote name');
|
||||
}
|
||||
return remote;
|
||||
}
|
||||
|
||||
// Helper function to get the actual project path from the encoded project name
|
||||
async function getActualProjectPath(projectName) {
|
||||
try {
|
||||
@@ -126,14 +98,14 @@ async function validateGitRepository(projectPath) {
|
||||
|
||||
try {
|
||||
// Allow any directory that is inside a work tree (repo root or nested folder).
|
||||
const { stdout: insideWorkTreeOutput } = await spawnAsync('git', ['rev-parse', '--is-inside-work-tree'], { cwd: projectPath });
|
||||
const { stdout: insideWorkTreeOutput } = await execAsync('git rev-parse --is-inside-work-tree', { cwd: projectPath });
|
||||
const isInsideWorkTree = insideWorkTreeOutput.trim() === 'true';
|
||||
if (!isInsideWorkTree) {
|
||||
throw new Error('Not inside a git work tree');
|
||||
}
|
||||
|
||||
// Ensure git can resolve the repository root for this directory.
|
||||
await spawnAsync('git', ['rev-parse', '--show-toplevel'], { cwd: projectPath });
|
||||
await execAsync('git rev-parse --show-toplevel', { cwd: projectPath });
|
||||
} catch {
|
||||
throw new Error('Not a git repository. This directory does not contain a .git folder. Initialize a git repository with "git init" to use source control features.');
|
||||
}
|
||||
@@ -157,7 +129,7 @@ router.get('/status', async (req, res) => {
|
||||
let branch = 'main';
|
||||
let hasCommits = true;
|
||||
try {
|
||||
const { stdout: branchOutput } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: projectPath });
|
||||
const { stdout: branchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath });
|
||||
branch = branchOutput.trim();
|
||||
} catch (error) {
|
||||
// No HEAD exists - repository has no commits yet
|
||||
@@ -170,7 +142,7 @@ router.get('/status', async (req, res) => {
|
||||
}
|
||||
|
||||
// Get git status
|
||||
const { stdout: statusOutput } = await spawnAsync('git', ['status', '--porcelain'], { cwd: projectPath });
|
||||
const { stdout: statusOutput } = await execAsync('git status --porcelain', { cwd: projectPath });
|
||||
|
||||
const modified = [];
|
||||
const added = [];
|
||||
@@ -229,11 +201,8 @@ router.get('/diff', async (req, res) => {
|
||||
// Validate git repository
|
||||
await validateGitRepository(projectPath);
|
||||
|
||||
// Validate file path
|
||||
validateFilePath(file);
|
||||
|
||||
// Check if file is untracked or deleted
|
||||
const { stdout: statusOutput } = await spawnAsync('git', ['status', '--porcelain', file], { cwd: projectPath });
|
||||
const { stdout: statusOutput } = await execAsync(`git status --porcelain "${file}"`, { cwd: projectPath });
|
||||
const isUntracked = statusOutput.startsWith('??');
|
||||
const isDeleted = statusOutput.trim().startsWith('D ') || statusOutput.trim().startsWith(' D');
|
||||
|
||||
@@ -254,21 +223,21 @@ router.get('/diff', async (req, res) => {
|
||||
}
|
||||
} else if (isDeleted) {
|
||||
// For deleted files, show the entire file content from HEAD as deletions
|
||||
const { stdout: fileContent } = await spawnAsync('git', ['show', `HEAD:${file}`], { cwd: projectPath });
|
||||
const { stdout: fileContent } = await execAsync(`git show HEAD:"${file}"`, { cwd: projectPath });
|
||||
const lines = fileContent.split('\n');
|
||||
diff = `--- a/${file}\n+++ /dev/null\n@@ -1,${lines.length} +0,0 @@\n` +
|
||||
lines.map(line => `-${line}`).join('\n');
|
||||
} else {
|
||||
// Get diff for tracked files
|
||||
// First check for unstaged changes (working tree vs index)
|
||||
const { stdout: unstagedDiff } = await spawnAsync('git', ['diff', '--', file], { cwd: projectPath });
|
||||
const { stdout: unstagedDiff } = await execAsync(`git diff -- "${file}"`, { cwd: projectPath });
|
||||
|
||||
if (unstagedDiff) {
|
||||
// Show unstaged changes if they exist
|
||||
diff = stripDiffHeaders(unstagedDiff);
|
||||
} else {
|
||||
// If no unstaged changes, check for staged changes (index vs HEAD)
|
||||
const { stdout: stagedDiff } = await spawnAsync('git', ['diff', '--cached', '--', file], { cwd: projectPath });
|
||||
const { stdout: stagedDiff } = await execAsync(`git diff --cached -- "${file}"`, { cwd: projectPath });
|
||||
diff = stripDiffHeaders(stagedDiff) || '';
|
||||
}
|
||||
}
|
||||
@@ -294,11 +263,8 @@ router.get('/file-with-diff', async (req, res) => {
|
||||
// Validate git repository
|
||||
await validateGitRepository(projectPath);
|
||||
|
||||
// Validate file path
|
||||
validateFilePath(file);
|
||||
|
||||
// Check file status
|
||||
const { stdout: statusOutput } = await spawnAsync('git', ['status', '--porcelain', file], { cwd: projectPath });
|
||||
const { stdout: statusOutput } = await execAsync(`git status --porcelain "${file}"`, { cwd: projectPath });
|
||||
const isUntracked = statusOutput.startsWith('??');
|
||||
const isDeleted = statusOutput.trim().startsWith('D ') || statusOutput.trim().startsWith(' D');
|
||||
|
||||
@@ -307,7 +273,7 @@ router.get('/file-with-diff', async (req, res) => {
|
||||
|
||||
if (isDeleted) {
|
||||
// For deleted files, get content from HEAD
|
||||
const { stdout: headContent } = await spawnAsync('git', ['show', `HEAD:${file}`], { cwd: projectPath });
|
||||
const { stdout: headContent } = await execAsync(`git show HEAD:"${file}"`, { cwd: projectPath });
|
||||
oldContent = headContent;
|
||||
currentContent = headContent; // Show the deleted content in editor
|
||||
} else {
|
||||
@@ -325,7 +291,7 @@ router.get('/file-with-diff', async (req, res) => {
|
||||
if (!isUntracked) {
|
||||
// Get the old content from HEAD for tracked files
|
||||
try {
|
||||
const { stdout: headContent } = await spawnAsync('git', ['show', `HEAD:${file}`], { cwd: projectPath });
|
||||
const { stdout: headContent } = await execAsync(`git show HEAD:"${file}"`, { cwd: projectPath });
|
||||
oldContent = headContent;
|
||||
} catch (error) {
|
||||
// File might be newly added to git (staged but not committed)
|
||||
@@ -362,17 +328,17 @@ router.post('/initial-commit', async (req, res) => {
|
||||
|
||||
// Check if there are already commits
|
||||
try {
|
||||
await spawnAsync('git', ['rev-parse', 'HEAD'], { cwd: projectPath });
|
||||
await execAsync('git rev-parse HEAD', { cwd: projectPath });
|
||||
return res.status(400).json({ error: 'Repository already has commits. Use regular commit instead.' });
|
||||
} catch (error) {
|
||||
// No HEAD - this is good, we can create initial commit
|
||||
}
|
||||
|
||||
// Add all files
|
||||
await spawnAsync('git', ['add', '.'], { cwd: projectPath });
|
||||
await execAsync('git add .', { cwd: projectPath });
|
||||
|
||||
// Create initial commit
|
||||
const { stdout } = await spawnAsync('git', ['commit', '-m', 'Initial commit'], { cwd: projectPath });
|
||||
const { stdout } = await execAsync('git commit -m "Initial commit"', { cwd: projectPath });
|
||||
|
||||
res.json({ success: true, output: stdout, message: 'Initial commit created successfully' });
|
||||
} catch (error) {
|
||||
@@ -406,12 +372,11 @@ router.post('/commit', async (req, res) => {
|
||||
|
||||
// Stage selected files
|
||||
for (const file of files) {
|
||||
validateFilePath(file);
|
||||
await spawnAsync('git', ['add', file], { cwd: projectPath });
|
||||
await execAsync(`git add "${file}"`, { cwd: projectPath });
|
||||
}
|
||||
|
||||
|
||||
// Commit with message
|
||||
const { stdout } = await spawnAsync('git', ['commit', '-m', message], { cwd: projectPath });
|
||||
const { stdout } = await execAsync(`git commit -m "${message.replace(/"/g, '\\"')}"`, { cwd: projectPath });
|
||||
|
||||
res.json({ success: true, output: stdout });
|
||||
} catch (error) {
|
||||
@@ -435,7 +400,7 @@ router.get('/branches', async (req, res) => {
|
||||
await validateGitRepository(projectPath);
|
||||
|
||||
// Get all branches
|
||||
const { stdout } = await spawnAsync('git', ['branch', '-a'], { cwd: projectPath });
|
||||
const { stdout } = await execAsync('git branch -a', { cwd: projectPath });
|
||||
|
||||
// Parse branches
|
||||
const branches = stdout
|
||||
@@ -474,8 +439,7 @@ router.post('/checkout', async (req, res) => {
|
||||
const projectPath = await getActualProjectPath(project);
|
||||
|
||||
// Checkout the branch
|
||||
validateBranchName(branch);
|
||||
const { stdout } = await spawnAsync('git', ['checkout', branch], { cwd: projectPath });
|
||||
const { stdout } = await execAsync(`git checkout "${branch}"`, { cwd: projectPath });
|
||||
|
||||
res.json({ success: true, output: stdout });
|
||||
} catch (error) {
|
||||
@@ -496,8 +460,7 @@ router.post('/create-branch', async (req, res) => {
|
||||
const projectPath = await getActualProjectPath(project);
|
||||
|
||||
// Create and checkout new branch
|
||||
validateBranchName(branch);
|
||||
const { stdout } = await spawnAsync('git', ['checkout', '-b', branch], { cwd: projectPath });
|
||||
const { stdout } = await execAsync(`git checkout -b "${branch}"`, { cwd: projectPath });
|
||||
|
||||
res.json({ success: true, output: stdout });
|
||||
} catch (error) {
|
||||
@@ -546,8 +509,8 @@ router.get('/commits', async (req, res) => {
|
||||
// Get stats for each commit
|
||||
for (const commit of commits) {
|
||||
try {
|
||||
const { stdout: stats } = await spawnAsync(
|
||||
'git', ['show', '--stat', '--format=', commit.hash],
|
||||
const { stdout: stats } = await execAsync(
|
||||
`git show --stat --format='' ${commit.hash}`,
|
||||
{ cwd: projectPath }
|
||||
);
|
||||
commit.stats = stats.trim().split('\n').pop(); // Get the summary line
|
||||
@@ -573,13 +536,10 @@ router.get('/commit-diff', async (req, res) => {
|
||||
|
||||
try {
|
||||
const projectPath = await getActualProjectPath(project);
|
||||
|
||||
// Validate commit reference (defense-in-depth)
|
||||
validateCommitRef(commit);
|
||||
|
||||
|
||||
// Get diff for the commit
|
||||
const { stdout } = await spawnAsync(
|
||||
'git', ['show', commit],
|
||||
const { stdout } = await execAsync(
|
||||
`git show ${commit}`,
|
||||
{ cwd: projectPath }
|
||||
);
|
||||
|
||||
@@ -610,9 +570,8 @@ router.post('/generate-commit-message', async (req, res) => {
|
||||
let diffContext = '';
|
||||
for (const file of files) {
|
||||
try {
|
||||
validateFilePath(file);
|
||||
const { stdout } = await spawnAsync(
|
||||
'git', ['diff', 'HEAD', '--', file],
|
||||
const { stdout } = await execAsync(
|
||||
`git diff HEAD -- "${file}"`,
|
||||
{ cwd: projectPath }
|
||||
);
|
||||
if (stdout) {
|
||||
@@ -805,14 +764,14 @@ router.get('/remote-status', async (req, res) => {
|
||||
await validateGitRepository(projectPath);
|
||||
|
||||
// Get current branch
|
||||
const { stdout: currentBranch } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: projectPath });
|
||||
const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath });
|
||||
const branch = currentBranch.trim();
|
||||
|
||||
// Check if there's a remote tracking branch (smart detection)
|
||||
let trackingBranch;
|
||||
let remoteName;
|
||||
try {
|
||||
const { stdout } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', `${branch}@{upstream}`], { cwd: projectPath });
|
||||
const { stdout } = await execAsync(`git rev-parse --abbrev-ref ${branch}@{upstream}`, { cwd: projectPath });
|
||||
trackingBranch = stdout.trim();
|
||||
remoteName = trackingBranch.split('/')[0]; // Extract remote name (e.g., "origin/main" -> "origin")
|
||||
} catch (error) {
|
||||
@@ -820,7 +779,7 @@ router.get('/remote-status', async (req, res) => {
|
||||
let hasRemote = false;
|
||||
let remoteName = null;
|
||||
try {
|
||||
const { stdout } = await spawnAsync('git', ['remote'], { cwd: projectPath });
|
||||
const { stdout } = await execAsync('git remote', { cwd: projectPath });
|
||||
const remotes = stdout.trim().split('\n').filter(r => r.trim());
|
||||
if (remotes.length > 0) {
|
||||
hasRemote = true;
|
||||
@@ -829,8 +788,8 @@ router.get('/remote-status', async (req, res) => {
|
||||
} catch (remoteError) {
|
||||
// No remotes configured
|
||||
}
|
||||
|
||||
return res.json({
|
||||
|
||||
return res.json({
|
||||
hasRemote,
|
||||
hasUpstream: false,
|
||||
branch,
|
||||
@@ -840,8 +799,8 @@ router.get('/remote-status', async (req, res) => {
|
||||
}
|
||||
|
||||
// Get ahead/behind counts
|
||||
const { stdout: countOutput } = await spawnAsync(
|
||||
'git', ['rev-list', '--count', '--left-right', `${trackingBranch}...HEAD`],
|
||||
const { stdout: countOutput } = await execAsync(
|
||||
`git rev-list --count --left-right ${trackingBranch}...HEAD`,
|
||||
{ cwd: projectPath }
|
||||
);
|
||||
|
||||
@@ -876,21 +835,20 @@ router.post('/fetch', async (req, res) => {
|
||||
await validateGitRepository(projectPath);
|
||||
|
||||
// Get current branch and its upstream remote
|
||||
const { stdout: currentBranch } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: projectPath });
|
||||
const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath });
|
||||
const branch = currentBranch.trim();
|
||||
|
||||
let remoteName = 'origin'; // fallback
|
||||
try {
|
||||
const { stdout } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', `${branch}@{upstream}`], { cwd: projectPath });
|
||||
const { stdout } = await execAsync(`git rev-parse --abbrev-ref ${branch}@{upstream}`, { cwd: projectPath });
|
||||
remoteName = stdout.trim().split('/')[0]; // Extract remote name
|
||||
} catch (error) {
|
||||
// No upstream, try to fetch from origin anyway
|
||||
console.log('No upstream configured, using origin as fallback');
|
||||
}
|
||||
|
||||
validateRemoteName(remoteName);
|
||||
const { stdout } = await spawnAsync('git', ['fetch', remoteName], { cwd: projectPath });
|
||||
|
||||
const { stdout } = await execAsync(`git fetch ${remoteName}`, { cwd: projectPath });
|
||||
|
||||
res.json({ success: true, output: stdout || 'Fetch completed successfully', remoteName });
|
||||
} catch (error) {
|
||||
console.error('Git fetch error:', error);
|
||||
@@ -918,13 +876,13 @@ router.post('/pull', async (req, res) => {
|
||||
await validateGitRepository(projectPath);
|
||||
|
||||
// Get current branch and its upstream remote
|
||||
const { stdout: currentBranch } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: projectPath });
|
||||
const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath });
|
||||
const branch = currentBranch.trim();
|
||||
|
||||
let remoteName = 'origin'; // fallback
|
||||
let remoteBranch = branch; // fallback
|
||||
try {
|
||||
const { stdout } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', `${branch}@{upstream}`], { cwd: projectPath });
|
||||
const { stdout } = await execAsync(`git rev-parse --abbrev-ref ${branch}@{upstream}`, { cwd: projectPath });
|
||||
const tracking = stdout.trim();
|
||||
remoteName = tracking.split('/')[0]; // Extract remote name
|
||||
remoteBranch = tracking.split('/').slice(1).join('/'); // Extract branch name
|
||||
@@ -933,19 +891,17 @@ router.post('/pull', async (req, res) => {
|
||||
console.log('No upstream configured, using origin/branch as fallback');
|
||||
}
|
||||
|
||||
validateRemoteName(remoteName);
|
||||
validateBranchName(remoteBranch);
|
||||
const { stdout } = await spawnAsync('git', ['pull', remoteName, remoteBranch], { cwd: projectPath });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
output: stdout || 'Pull completed successfully',
|
||||
const { stdout } = await execAsync(`git pull ${remoteName} ${remoteBranch}`, { cwd: projectPath });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
output: stdout || 'Pull completed successfully',
|
||||
remoteName,
|
||||
remoteBranch
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Git pull error:', error);
|
||||
|
||||
|
||||
// Enhanced error handling for common pull scenarios
|
||||
let errorMessage = 'Pull failed';
|
||||
let details = error.message;
|
||||
@@ -987,13 +943,13 @@ router.post('/push', async (req, res) => {
|
||||
await validateGitRepository(projectPath);
|
||||
|
||||
// Get current branch and its upstream remote
|
||||
const { stdout: currentBranch } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: projectPath });
|
||||
const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath });
|
||||
const branch = currentBranch.trim();
|
||||
|
||||
let remoteName = 'origin'; // fallback
|
||||
let remoteBranch = branch; // fallback
|
||||
try {
|
||||
const { stdout } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', `${branch}@{upstream}`], { cwd: projectPath });
|
||||
const { stdout } = await execAsync(`git rev-parse --abbrev-ref ${branch}@{upstream}`, { cwd: projectPath });
|
||||
const tracking = stdout.trim();
|
||||
remoteName = tracking.split('/')[0]; // Extract remote name
|
||||
remoteBranch = tracking.split('/').slice(1).join('/'); // Extract branch name
|
||||
@@ -1002,13 +958,11 @@ router.post('/push', async (req, res) => {
|
||||
console.log('No upstream configured, using origin/branch as fallback');
|
||||
}
|
||||
|
||||
validateRemoteName(remoteName);
|
||||
validateBranchName(remoteBranch);
|
||||
const { stdout } = await spawnAsync('git', ['push', remoteName, remoteBranch], { cwd: projectPath });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
output: stdout || 'Push completed successfully',
|
||||
const { stdout } = await execAsync(`git push ${remoteName} ${remoteBranch}`, { cwd: projectPath });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
output: stdout || 'Push completed successfully',
|
||||
remoteName,
|
||||
remoteBranch
|
||||
});
|
||||
@@ -1058,39 +1012,35 @@ router.post('/publish', async (req, res) => {
|
||||
const projectPath = await getActualProjectPath(project);
|
||||
await validateGitRepository(projectPath);
|
||||
|
||||
// Validate branch name
|
||||
validateBranchName(branch);
|
||||
|
||||
// Get current branch to verify it matches the requested branch
|
||||
const { stdout: currentBranch } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: projectPath });
|
||||
const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath });
|
||||
const currentBranchName = currentBranch.trim();
|
||||
|
||||
|
||||
if (currentBranchName !== branch) {
|
||||
return res.status(400).json({
|
||||
error: `Branch mismatch. Current branch is ${currentBranchName}, but trying to publish ${branch}`
|
||||
return res.status(400).json({
|
||||
error: `Branch mismatch. Current branch is ${currentBranchName}, but trying to publish ${branch}`
|
||||
});
|
||||
}
|
||||
|
||||
// Check if remote exists
|
||||
let remoteName = 'origin';
|
||||
try {
|
||||
const { stdout } = await spawnAsync('git', ['remote'], { cwd: projectPath });
|
||||
const { stdout } = await execAsync('git remote', { cwd: projectPath });
|
||||
const remotes = stdout.trim().split('\n').filter(r => r.trim());
|
||||
if (remotes.length === 0) {
|
||||
return res.status(400).json({
|
||||
error: 'No remote repository configured. Add a remote with: git remote add origin <url>'
|
||||
return res.status(400).json({
|
||||
error: 'No remote repository configured. Add a remote with: git remote add origin <url>'
|
||||
});
|
||||
}
|
||||
remoteName = remotes.includes('origin') ? 'origin' : remotes[0];
|
||||
} catch (error) {
|
||||
return res.status(400).json({
|
||||
error: 'No remote repository configured. Add a remote with: git remote add origin <url>'
|
||||
return res.status(400).json({
|
||||
error: 'No remote repository configured. Add a remote with: git remote add origin <url>'
|
||||
});
|
||||
}
|
||||
|
||||
// Publish the branch (set upstream and push)
|
||||
validateRemoteName(remoteName);
|
||||
const { stdout } = await spawnAsync('git', ['push', '--set-upstream', remoteName, branch], { cwd: projectPath });
|
||||
const { stdout } = await execAsync(`git push --set-upstream ${remoteName} ${branch}`, { cwd: projectPath });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
@@ -1138,12 +1088,9 @@ router.post('/discard', async (req, res) => {
|
||||
const projectPath = await getActualProjectPath(project);
|
||||
await validateGitRepository(projectPath);
|
||||
|
||||
// Validate file path
|
||||
validateFilePath(file);
|
||||
|
||||
// Check file status to determine correct discard command
|
||||
const { stdout: statusOutput } = await spawnAsync('git', ['status', '--porcelain', file], { cwd: projectPath });
|
||||
|
||||
const { stdout: statusOutput } = await execAsync(`git status --porcelain "${file}"`, { cwd: projectPath });
|
||||
|
||||
if (!statusOutput.trim()) {
|
||||
return res.status(400).json({ error: 'No changes to discard for this file' });
|
||||
}
|
||||
@@ -1162,10 +1109,10 @@ router.post('/discard', async (req, res) => {
|
||||
}
|
||||
} else if (status.includes('M') || status.includes('D')) {
|
||||
// Modified or deleted file - restore from HEAD
|
||||
await spawnAsync('git', ['restore', file], { cwd: projectPath });
|
||||
await execAsync(`git restore "${file}"`, { cwd: projectPath });
|
||||
} else if (status.includes('A')) {
|
||||
// Added file - unstage it
|
||||
await spawnAsync('git', ['reset', 'HEAD', file], { cwd: projectPath });
|
||||
await execAsync(`git reset HEAD "${file}"`, { cwd: projectPath });
|
||||
}
|
||||
|
||||
res.json({ success: true, message: `Changes discarded for ${file}` });
|
||||
@@ -1187,11 +1134,8 @@ router.post('/delete-untracked', async (req, res) => {
|
||||
const projectPath = await getActualProjectPath(project);
|
||||
await validateGitRepository(projectPath);
|
||||
|
||||
// Validate file path
|
||||
validateFilePath(file);
|
||||
|
||||
// Check if file is actually untracked
|
||||
const { stdout: statusOutput } = await spawnAsync('git', ['status', '--porcelain', file], { cwd: projectPath });
|
||||
const { stdout: statusOutput } = await execAsync(`git status --porcelain "${file}"`, { cwd: projectPath });
|
||||
|
||||
if (!statusOutput.trim()) {
|
||||
return res.status(400).json({ error: 'File is not untracked or does not exist' });
|
||||
|
||||
@@ -311,11 +311,13 @@ router.post('/create-workspace', async (req, res) => {
|
||||
* Helper function to get GitHub token from database
|
||||
*/
|
||||
async function getGithubTokenById(tokenId, userId) {
|
||||
const { db } = await import('../database/db.js');
|
||||
const { getDatabase } = await import('../database/db.js');
|
||||
const db = await getDatabase();
|
||||
|
||||
const credential = db.prepare(
|
||||
'SELECT * FROM user_credentials WHERE id = ? AND user_id = ? AND credential_type = ? AND is_active = 1'
|
||||
).get(tokenId, userId, 'github_token');
|
||||
const credential = await db.get(
|
||||
'SELECT * FROM user_credentials WHERE id = ? AND user_id = ? AND credential_type = ? AND is_active = 1',
|
||||
[tokenId, userId, 'github_token']
|
||||
);
|
||||
|
||||
// Return in the expected format (github_token field for compatibility)
|
||||
if (credential) {
|
||||
|
||||
@@ -2,29 +2,12 @@ import express from 'express';
|
||||
import { userDb } from '../database/db.js';
|
||||
import { authenticateToken } from '../middleware/auth.js';
|
||||
import { getSystemGitConfig } from '../utils/gitConfig.js';
|
||||
import { spawn } from 'child_process';
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const router = express.Router();
|
||||
|
||||
function spawnAsync(command, args, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, { ...options, shell: false });
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
child.stdout.on('data', (data) => { stdout += data.toString(); });
|
||||
child.stderr.on('data', (data) => { stderr += data.toString(); });
|
||||
child.on('error', (error) => { reject(error); });
|
||||
child.on('close', (code) => {
|
||||
if (code === 0) { resolve({ stdout, stderr }); return; }
|
||||
const error = new Error(`Command failed: ${command} ${args.join(' ')}`);
|
||||
error.code = code;
|
||||
error.stdout = stdout;
|
||||
error.stderr = stderr;
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
router.get('/git-config', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
@@ -72,8 +55,8 @@ router.post('/git-config', authenticateToken, async (req, res) => {
|
||||
userDb.updateGitConfig(userId, gitName, gitEmail);
|
||||
|
||||
try {
|
||||
await spawnAsync('git', ['config', '--global', 'user.name', gitName]);
|
||||
await spawnAsync('git', ['config', '--global', 'user.email', gitEmail]);
|
||||
await execAsync(`git config --global user.name "${gitName.replace(/"/g, '\\"')}"`);
|
||||
await execAsync(`git config --global user.email "${gitEmail.replace(/"/g, '\\"')}"`);
|
||||
console.log(`Applied git config globally: ${gitName} <${gitEmail}>`);
|
||||
} catch (gitError) {
|
||||
console.error('Error applying git config:', gitError);
|
||||
|
||||
@@ -1,17 +1,7 @@
|
||||
import { spawn } from 'child_process';
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
|
||||
function spawnAsync(command, args) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, { shell: false });
|
||||
let stdout = '';
|
||||
child.stdout.on('data', (data) => { stdout += data.toString(); });
|
||||
child.on('error', (error) => { reject(error); });
|
||||
child.on('close', (code) => {
|
||||
if (code === 0) { resolve({ stdout }); return; }
|
||||
reject(new Error(`Command failed with code ${code}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
/**
|
||||
* Read git configuration from system's global git config
|
||||
@@ -20,8 +10,8 @@ function spawnAsync(command, args) {
|
||||
export async function getSystemGitConfig() {
|
||||
try {
|
||||
const [nameResult, emailResult] = await Promise.all([
|
||||
spawnAsync('git', ['config', '--global', 'user.name']).catch(() => ({ stdout: '' })),
|
||||
spawnAsync('git', ['config', '--global', 'user.email']).catch(() => ({ stdout: '' }))
|
||||
execAsync('git config --global user.name').catch(() => ({ stdout: '' })),
|
||||
execAsync('git config --global user.email').catch(() => ({ stdout: '' }))
|
||||
]);
|
||||
|
||||
return {
|
||||
|
||||
@@ -82,8 +82,6 @@ export function useChatSessionState({
|
||||
const [showLoadAllOverlay, setShowLoadAllOverlay] = useState(false);
|
||||
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [searchTarget, setSearchTarget] = useState<{ timestamp?: string; uuid?: string; snippet?: string } | null>(null);
|
||||
const searchScrollActiveRef = useRef(false);
|
||||
const isLoadingSessionRef = useRef(false);
|
||||
const isLoadingMoreRef = useRef(false);
|
||||
const allMessagesLoadedRef = useRef(false);
|
||||
@@ -303,14 +301,12 @@ export function useChatSessionState({
|
||||
const isInitialLoadRef = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!searchScrollActiveRef.current) {
|
||||
pendingInitialScrollRef.current = true;
|
||||
setVisibleMessageCount(INITIAL_VISIBLE_MESSAGES);
|
||||
}
|
||||
pendingInitialScrollRef.current = true;
|
||||
topLoadLockRef.current = false;
|
||||
pendingScrollRestoreRef.current = null;
|
||||
prevSessionMessagesLengthRef.current = 0;
|
||||
isInitialLoadRef.current = true;
|
||||
setVisibleMessageCount(INITIAL_VISIBLE_MESSAGES);
|
||||
setIsUserScrolledUp(false);
|
||||
}, [selectedProject?.name, selectedSession?.id]);
|
||||
|
||||
@@ -325,11 +321,9 @@ export function useChatSessionState({
|
||||
}
|
||||
|
||||
pendingInitialScrollRef.current = false;
|
||||
if (!searchScrollActiveRef.current) {
|
||||
setTimeout(() => {
|
||||
scrollToBottom();
|
||||
}, 200);
|
||||
}
|
||||
setTimeout(() => {
|
||||
scrollToBottom();
|
||||
}, 200);
|
||||
}, [chatMessages.length, isLoadingSessionMessages, scrollToBottom]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -508,28 +502,13 @@ export function useChatSessionState({
|
||||
selectedSession,
|
||||
]);
|
||||
|
||||
// Detect search navigation target from selectedSession object reference change
|
||||
// This must be a separate effect because the loading effect depends on selectedSession?.id
|
||||
// which doesn't change when clicking a search result for the already-loaded session
|
||||
useEffect(() => {
|
||||
const session = selectedSession as Record<string, unknown> | null;
|
||||
const targetSnippet = session?.__searchTargetSnippet;
|
||||
const targetTimestamp = session?.__searchTargetTimestamp;
|
||||
if (typeof targetSnippet === 'string' && targetSnippet) {
|
||||
searchScrollActiveRef.current = true;
|
||||
setSearchTarget({
|
||||
snippet: targetSnippet,
|
||||
timestamp: typeof targetTimestamp === 'string' ? targetTimestamp : undefined,
|
||||
});
|
||||
}
|
||||
}, [selectedSession]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedSession?.id) {
|
||||
pendingViewSessionRef.current = null;
|
||||
}
|
||||
}, [pendingViewSessionRef, selectedSession?.id]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
// Only sync sessionMessages to chatMessages when:
|
||||
// 1. Not currently loading (to avoid overwriting user's just-sent message)
|
||||
@@ -554,110 +533,6 @@ export function useChatSessionState({
|
||||
}
|
||||
}, [chatMessages, selectedProject]);
|
||||
|
||||
// Scroll to search target message after messages are loaded
|
||||
useEffect(() => {
|
||||
if (!searchTarget || chatMessages.length === 0 || isLoadingSessionMessages) return;
|
||||
|
||||
const target = searchTarget;
|
||||
// Clear immediately to prevent re-triggering
|
||||
setSearchTarget(null);
|
||||
|
||||
const scrollToTarget = async () => {
|
||||
// Always load all messages when navigating from search
|
||||
// (hasMoreMessages may not be set yet due to race with loading effect)
|
||||
if (!allMessagesLoadedRef.current && selectedSession && selectedProject) {
|
||||
const sessionProvider = selectedSession.__provider || 'claude';
|
||||
if (sessionProvider !== 'cursor') {
|
||||
try {
|
||||
const response = await (api.sessionMessages as any)(
|
||||
selectedProject.name,
|
||||
selectedSession.id,
|
||||
null,
|
||||
0,
|
||||
sessionProvider,
|
||||
);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const allMessages = data.messages || data;
|
||||
setSessionMessages(Array.isArray(allMessages) ? allMessages : []);
|
||||
setHasMoreMessages(false);
|
||||
setTotalMessages(Array.isArray(allMessages) ? allMessages.length : 0);
|
||||
messagesOffsetRef.current = Array.isArray(allMessages) ? allMessages.length : 0;
|
||||
setVisibleMessageCount(Infinity);
|
||||
setAllMessagesLoaded(true);
|
||||
allMessagesLoadedRef.current = true;
|
||||
// Wait for messages to render after state update
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
}
|
||||
} catch {
|
||||
// Fall through and scroll in current messages
|
||||
}
|
||||
}
|
||||
}
|
||||
setVisibleMessageCount(Infinity);
|
||||
|
||||
// Retry finding the element in the DOM until React finishes rendering all messages
|
||||
const findAndScroll = (retriesLeft: number) => {
|
||||
const container = scrollContainerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
let targetElement: Element | null = null;
|
||||
|
||||
// Match by snippet text content (most reliable)
|
||||
if (target.snippet) {
|
||||
const cleanSnippet = target.snippet.replace(/^\.{3}/, '').replace(/\.{3}$/, '').trim();
|
||||
// Use a contiguous substring from the snippet (don't filter words, it breaks matching)
|
||||
const searchPhrase = cleanSnippet.slice(0, 80).toLowerCase().trim();
|
||||
|
||||
if (searchPhrase.length >= 10) {
|
||||
const messageElements = container.querySelectorAll('.chat-message');
|
||||
for (const el of messageElements) {
|
||||
const text = (el.textContent || '').toLowerCase();
|
||||
if (text.includes(searchPhrase)) {
|
||||
targetElement = el;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to timestamp matching
|
||||
if (!targetElement && target.timestamp) {
|
||||
const targetDate = new Date(target.timestamp).getTime();
|
||||
const messageElements = container.querySelectorAll('[data-message-timestamp]');
|
||||
let closestDiff = Infinity;
|
||||
|
||||
for (const el of messageElements) {
|
||||
const ts = el.getAttribute('data-message-timestamp');
|
||||
if (!ts) continue;
|
||||
const diff = Math.abs(new Date(ts).getTime() - targetDate);
|
||||
if (diff < closestDiff) {
|
||||
closestDiff = diff;
|
||||
targetElement = el;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (targetElement) {
|
||||
targetElement.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||||
targetElement.classList.add('search-highlight-flash');
|
||||
setTimeout(() => targetElement?.classList.remove('search-highlight-flash'), 4000);
|
||||
searchScrollActiveRef.current = false;
|
||||
} else if (retriesLeft > 0) {
|
||||
setTimeout(() => findAndScroll(retriesLeft - 1), 200);
|
||||
} else {
|
||||
searchScrollActiveRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Start polling after a short delay to let React begin rendering
|
||||
setTimeout(() => findAndScroll(15), 150);
|
||||
};
|
||||
|
||||
scrollToTarget();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [chatMessages.length, isLoadingSessionMessages, searchTarget]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedProject || !selectedSession?.id || selectedSession.id.startsWith('new-session-')) {
|
||||
setTokenBudget(null);
|
||||
@@ -713,10 +588,6 @@ export function useChatSessionState({
|
||||
return;
|
||||
}
|
||||
|
||||
if (searchScrollActiveRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (autoScrollToBottom) {
|
||||
if (!isUserScrolledUp) {
|
||||
setTimeout(() => scrollToBottom(), 50);
|
||||
|
||||
@@ -96,7 +96,6 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, o
|
||||
return (
|
||||
<div
|
||||
ref={messageRef}
|
||||
data-message-timestamp={message.timestamp || undefined}
|
||||
className={`chat-message ${message.type} ${isGrouped ? 'grouped' : ''} ${message.type === 'user' ? 'flex justify-end px-3 sm:px-0' : 'px-3 sm:px-0'}`}
|
||||
>
|
||||
{message.type === 'user' ? (
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type React from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import { api } from '../../../utils/api';
|
||||
import type { Project, ProjectSession, SessionProvider } from '../../../types/app';
|
||||
@@ -20,44 +19,6 @@ import {
|
||||
sortProjects,
|
||||
} from '../utils/utils';
|
||||
|
||||
type SnippetHighlight = {
|
||||
start: number;
|
||||
end: number;
|
||||
};
|
||||
|
||||
type ConversationMatch = {
|
||||
role: string;
|
||||
snippet: string;
|
||||
highlights: SnippetHighlight[];
|
||||
timestamp: string | null;
|
||||
provider?: string;
|
||||
messageUuid?: string | null;
|
||||
};
|
||||
|
||||
type ConversationSession = {
|
||||
sessionId: string;
|
||||
sessionSummary: string;
|
||||
provider?: string;
|
||||
matches: ConversationMatch[];
|
||||
};
|
||||
|
||||
type ConversationProjectResult = {
|
||||
projectName: string;
|
||||
projectDisplayName: string;
|
||||
sessions: ConversationSession[];
|
||||
};
|
||||
|
||||
export type ConversationSearchResults = {
|
||||
results: ConversationProjectResult[];
|
||||
totalMatches: number;
|
||||
query: string;
|
||||
};
|
||||
|
||||
export type SearchProgress = {
|
||||
scannedProjects: number;
|
||||
totalProjects: number;
|
||||
};
|
||||
|
||||
type UseSidebarControllerArgs = {
|
||||
projects: Project[];
|
||||
selectedProject: Project | null;
|
||||
@@ -110,13 +71,6 @@ export function useSidebarController({
|
||||
const [sessionDeleteConfirmation, setSessionDeleteConfirmation] = useState<SessionDeleteConfirmation | null>(null);
|
||||
const [showVersionModal, setShowVersionModal] = useState(false);
|
||||
const [starredProjects, setStarredProjects] = useState<Set<string>>(() => loadStarredProjects());
|
||||
const [searchMode, setSearchMode] = useState<'projects' | 'conversations'>('projects');
|
||||
const [conversationResults, setConversationResults] = useState<ConversationSearchResults | null>(null);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [searchProgress, setSearchProgress] = useState<SearchProgress | null>(null);
|
||||
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const searchSeqRef = useRef(0);
|
||||
const eventSourceRef = useRef<EventSource | null>(null);
|
||||
|
||||
const isSidebarCollapsed = !isMobile && !sidebarVisible;
|
||||
|
||||
@@ -186,116 +140,6 @@ export function useSidebarController({
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Debounced conversation search with SSE streaming
|
||||
useEffect(() => {
|
||||
if (searchTimeoutRef.current) {
|
||||
clearTimeout(searchTimeoutRef.current);
|
||||
}
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close();
|
||||
eventSourceRef.current = null;
|
||||
}
|
||||
|
||||
const query = searchFilter.trim();
|
||||
if (searchMode !== 'conversations' || query.length < 2) {
|
||||
searchSeqRef.current += 1;
|
||||
setConversationResults(null);
|
||||
setSearchProgress(null);
|
||||
setIsSearching(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSearching(true);
|
||||
const seq = ++searchSeqRef.current;
|
||||
|
||||
searchTimeoutRef.current = setTimeout(() => {
|
||||
if (seq !== searchSeqRef.current) return;
|
||||
|
||||
const url = api.searchConversationsUrl(query);
|
||||
const es = new EventSource(url);
|
||||
eventSourceRef.current = es;
|
||||
|
||||
const accumulated: ConversationProjectResult[] = [];
|
||||
let totalMatches = 0;
|
||||
|
||||
es.addEventListener('result', (evt) => {
|
||||
if (seq !== searchSeqRef.current) { es.close(); return; }
|
||||
try {
|
||||
const data = JSON.parse(evt.data) as {
|
||||
projectResult: ConversationProjectResult;
|
||||
totalMatches: number;
|
||||
scannedProjects: number;
|
||||
totalProjects: number;
|
||||
};
|
||||
accumulated.push(data.projectResult);
|
||||
totalMatches = data.totalMatches;
|
||||
setConversationResults({ results: [...accumulated], totalMatches, query });
|
||||
setSearchProgress({ scannedProjects: data.scannedProjects, totalProjects: data.totalProjects });
|
||||
} catch {
|
||||
// Ignore malformed SSE data
|
||||
}
|
||||
});
|
||||
|
||||
es.addEventListener('progress', (evt) => {
|
||||
if (seq !== searchSeqRef.current) { es.close(); return; }
|
||||
try {
|
||||
const data = JSON.parse(evt.data) as { totalMatches: number; scannedProjects: number; totalProjects: number };
|
||||
totalMatches = data.totalMatches;
|
||||
setSearchProgress({ scannedProjects: data.scannedProjects, totalProjects: data.totalProjects });
|
||||
} catch {
|
||||
// Ignore malformed SSE data
|
||||
}
|
||||
});
|
||||
|
||||
es.addEventListener('done', () => {
|
||||
if (seq !== searchSeqRef.current) { es.close(); return; }
|
||||
es.close();
|
||||
eventSourceRef.current = null;
|
||||
setIsSearching(false);
|
||||
setSearchProgress(null);
|
||||
if (accumulated.length === 0) {
|
||||
setConversationResults({ results: [], totalMatches: 0, query });
|
||||
}
|
||||
});
|
||||
|
||||
es.addEventListener('error', () => {
|
||||
if (seq !== searchSeqRef.current) { es.close(); return; }
|
||||
es.close();
|
||||
eventSourceRef.current = null;
|
||||
setIsSearching(false);
|
||||
setSearchProgress(null);
|
||||
if (accumulated.length === 0) {
|
||||
setConversationResults({ results: [], totalMatches: 0, query });
|
||||
}
|
||||
});
|
||||
}, 400);
|
||||
|
||||
return () => {
|
||||
if (searchTimeoutRef.current) {
|
||||
clearTimeout(searchTimeoutRef.current);
|
||||
}
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close();
|
||||
eventSourceRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [searchFilter, searchMode]);
|
||||
|
||||
const handleTouchClick = useCallback(
|
||||
(callback: () => void) =>
|
||||
(event: React.TouchEvent<HTMLElement>) => {
|
||||
const target = event.target as HTMLElement;
|
||||
if (target.closest('.overflow-y-auto') || target.closest('[data-scroll-container]')) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
callback();
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const toggleProject = useCallback((projectName: string) => {
|
||||
setExpandedProjects((prev) => {
|
||||
const next = new Set<string>();
|
||||
@@ -622,21 +466,6 @@ export function useSidebarController({
|
||||
setEditingName,
|
||||
setEditingSession,
|
||||
setEditingSessionName,
|
||||
searchMode,
|
||||
setSearchMode,
|
||||
conversationResults,
|
||||
isSearching,
|
||||
searchProgress,
|
||||
clearConversationResults: useCallback(() => {
|
||||
searchSeqRef.current += 1;
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close();
|
||||
eventSourceRef.current = null;
|
||||
}
|
||||
setIsSearching(false);
|
||||
setSearchProgress(null);
|
||||
setConversationResults(null);
|
||||
}, []),
|
||||
setSearchFilter,
|
||||
setDeleteConfirmation,
|
||||
setSessionDeleteConfirmation,
|
||||
|
||||
@@ -60,12 +60,6 @@ function Sidebar({
|
||||
editingSession,
|
||||
editingSessionName,
|
||||
searchFilter,
|
||||
searchMode,
|
||||
setSearchMode,
|
||||
conversationResults,
|
||||
isSearching,
|
||||
searchProgress,
|
||||
clearConversationResults,
|
||||
deletingProjects,
|
||||
deleteConfirmation,
|
||||
sessionDeleteConfirmation,
|
||||
@@ -226,37 +220,6 @@ function Sidebar({
|
||||
searchFilter={searchFilter}
|
||||
onSearchFilterChange={setSearchFilter}
|
||||
onClearSearchFilter={() => setSearchFilter('')}
|
||||
searchMode={searchMode}
|
||||
onSearchModeChange={(mode: 'projects' | 'conversations') => {
|
||||
setSearchMode(mode);
|
||||
if (mode === 'projects') clearConversationResults();
|
||||
}}
|
||||
conversationResults={conversationResults}
|
||||
isSearching={isSearching}
|
||||
searchProgress={searchProgress}
|
||||
onConversationResultClick={(projectName: string, sessionId: string, provider: string, messageTimestamp?: string | null, messageSnippet?: string | null) => {
|
||||
const resolvedProvider = (provider || 'claude') as SessionProvider;
|
||||
const project = projects.find(p => p.name === projectName);
|
||||
const searchTarget = { __searchTargetTimestamp: messageTimestamp || null, __searchTargetSnippet: messageSnippet || null };
|
||||
const sessionObj = {
|
||||
id: sessionId,
|
||||
__provider: resolvedProvider,
|
||||
__projectName: projectName,
|
||||
...searchTarget,
|
||||
};
|
||||
if (project) {
|
||||
handleProjectSelect(project);
|
||||
const sessions = getProjectSessions(project);
|
||||
const existing = sessions.find(s => s.id === sessionId);
|
||||
if (existing) {
|
||||
handleSessionClick({ ...existing, ...searchTarget }, projectName);
|
||||
} else {
|
||||
handleSessionClick(sessionObj, projectName);
|
||||
}
|
||||
} else {
|
||||
handleSessionClick(sessionObj, projectName);
|
||||
}
|
||||
}}
|
||||
onRefresh={() => {
|
||||
void refreshProjects();
|
||||
}}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { type ReactNode } from 'react';
|
||||
import { Folder, MessageSquare, Search } from 'lucide-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import { ScrollArea } from '../../../../shared/view/ui';
|
||||
import type { Project } from '../../../../types/app';
|
||||
@@ -7,33 +5,6 @@ import type { ReleaseInfo } from '../../../../types/sharedTypes';
|
||||
import SidebarFooter from './SidebarFooter';
|
||||
import SidebarHeader from './SidebarHeader';
|
||||
import SidebarProjectList, { type SidebarProjectListProps } from './SidebarProjectList';
|
||||
import type { ConversationSearchResults, SearchProgress } from '../../hooks/useSidebarController';
|
||||
|
||||
type SearchMode = 'projects' | 'conversations';
|
||||
|
||||
function HighlightedSnippet({ snippet, highlights }: { snippet: string; highlights: { start: number; end: number }[] }) {
|
||||
const parts: ReactNode[] = [];
|
||||
let cursor = 0;
|
||||
for (const h of highlights) {
|
||||
if (h.start > cursor) {
|
||||
parts.push(snippet.slice(cursor, h.start));
|
||||
}
|
||||
parts.push(
|
||||
<mark key={h.start} className="bg-yellow-200 dark:bg-yellow-800 text-foreground rounded-sm px-0.5">
|
||||
{snippet.slice(h.start, h.end)}
|
||||
</mark>
|
||||
);
|
||||
cursor = h.end;
|
||||
}
|
||||
if (cursor < snippet.length) {
|
||||
parts.push(snippet.slice(cursor));
|
||||
}
|
||||
return (
|
||||
<span className="text-xs text-muted-foreground leading-relaxed">
|
||||
{parts}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
type SidebarContentProps = {
|
||||
isPWA: boolean;
|
||||
@@ -43,12 +14,6 @@ type SidebarContentProps = {
|
||||
searchFilter: string;
|
||||
onSearchFilterChange: (value: string) => void;
|
||||
onClearSearchFilter: () => void;
|
||||
searchMode: SearchMode;
|
||||
onSearchModeChange: (mode: SearchMode) => void;
|
||||
conversationResults: ConversationSearchResults | null;
|
||||
isSearching: boolean;
|
||||
searchProgress: SearchProgress | null;
|
||||
onConversationResultClick: (projectName: string, sessionId: string, provider: string, messageTimestamp?: string | null, messageSnippet?: string | null) => void;
|
||||
onRefresh: () => void;
|
||||
isRefreshing: boolean;
|
||||
onCreateProject: () => void;
|
||||
@@ -70,12 +35,6 @@ export default function SidebarContent({
|
||||
searchFilter,
|
||||
onSearchFilterChange,
|
||||
onClearSearchFilter,
|
||||
searchMode,
|
||||
onSearchModeChange,
|
||||
conversationResults,
|
||||
isSearching,
|
||||
searchProgress,
|
||||
onConversationResultClick,
|
||||
onRefresh,
|
||||
isRefreshing,
|
||||
onCreateProject,
|
||||
@@ -88,9 +47,6 @@ export default function SidebarContent({
|
||||
projectListProps,
|
||||
t,
|
||||
}: SidebarContentProps) {
|
||||
const showConversationSearch = searchMode === 'conversations' && searchFilter.trim().length >= 2;
|
||||
const hasPartialResults = conversationResults && conversationResults.results.length > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex h-full flex-col bg-background/80 backdrop-blur-sm md:w-72 md:select-none"
|
||||
@@ -104,8 +60,6 @@ export default function SidebarContent({
|
||||
searchFilter={searchFilter}
|
||||
onSearchFilterChange={onSearchFilterChange}
|
||||
onClearSearchFilter={onClearSearchFilter}
|
||||
searchMode={searchMode}
|
||||
onSearchModeChange={onSearchModeChange}
|
||||
onRefresh={onRefresh}
|
||||
isRefreshing={isRefreshing}
|
||||
onCreateProject={onCreateProject}
|
||||
@@ -114,103 +68,7 @@ export default function SidebarContent({
|
||||
/>
|
||||
|
||||
<ScrollArea className="flex-1 overflow-y-auto overscroll-contain md:px-1.5 md:py-2">
|
||||
{showConversationSearch ? (
|
||||
isSearching && !hasPartialResults ? (
|
||||
<div className="text-center py-12 md:py-8 px-4">
|
||||
<div className="w-12 h-12 bg-muted rounded-lg flex items-center justify-center mx-auto mb-4 md:mb-3">
|
||||
<div className="w-6 h-6 animate-spin rounded-full border-2 border-muted-foreground border-t-transparent" />
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{t('search.searching')}</p>
|
||||
{searchProgress && (
|
||||
<p className="text-xs text-muted-foreground/60 mt-1">
|
||||
{t('search.projectsScanned', { count: searchProgress.scannedProjects })}/{searchProgress.totalProjects}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : !isSearching && conversationResults && conversationResults.results.length === 0 ? (
|
||||
<div className="text-center py-12 md:py-8 px-4">
|
||||
<div className="w-12 h-12 bg-muted rounded-lg flex items-center justify-center mx-auto mb-4 md:mb-3">
|
||||
<Search className="w-6 h-6 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="text-base font-medium text-foreground mb-2 md:mb-1">{t('search.noResults')}</h3>
|
||||
<p className="text-sm text-muted-foreground">{t('search.tryDifferentQuery')}</p>
|
||||
</div>
|
||||
) : hasPartialResults ? (
|
||||
<div className="space-y-3 px-2">
|
||||
<div className="flex items-center justify-between px-1">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('search.matches', { count: conversationResults.totalMatches })}
|
||||
</p>
|
||||
{isSearching && searchProgress && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-3 h-3 animate-spin rounded-full border-[1.5px] border-muted-foreground/40 border-t-primary" />
|
||||
<p className="text-[10px] text-muted-foreground/60">
|
||||
{searchProgress.scannedProjects}/{searchProgress.totalProjects}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isSearching && searchProgress && (
|
||||
<div className="mx-1 h-0.5 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary/60 rounded-full transition-all duration-300"
|
||||
style={{ width: `${Math.round((searchProgress.scannedProjects / searchProgress.totalProjects) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{conversationResults.results.map((projectResult) => (
|
||||
<div key={projectResult.projectName} className="space-y-1">
|
||||
<div className="flex items-center gap-1.5 px-1 py-1">
|
||||
<Folder className="w-3 h-3 text-muted-foreground flex-shrink-0" />
|
||||
<span className="text-xs font-medium text-foreground truncate">
|
||||
{projectResult.projectDisplayName}
|
||||
</span>
|
||||
</div>
|
||||
{projectResult.sessions.map((session) => (
|
||||
<button
|
||||
key={`${projectResult.projectName}-${session.sessionId}`}
|
||||
className="w-full text-left rounded-md px-2 py-2 hover:bg-accent/50 transition-colors"
|
||||
onClick={() => onConversationResultClick(
|
||||
projectResult.projectName,
|
||||
session.sessionId,
|
||||
session.provider || session.matches[0]?.provider || 'claude',
|
||||
session.matches[0]?.timestamp,
|
||||
session.matches[0]?.snippet
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
<MessageSquare className="w-3 h-3 text-primary flex-shrink-0" />
|
||||
<span className="text-xs font-medium text-foreground truncate">
|
||||
{session.sessionSummary}
|
||||
</span>
|
||||
{session.provider && session.provider !== 'claude' && (
|
||||
<span className="text-[9px] px-1 py-0.5 rounded bg-muted text-muted-foreground uppercase flex-shrink-0">
|
||||
{session.provider}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1 pl-4">
|
||||
{session.matches.map((match, idx) => (
|
||||
<div key={idx} className="flex items-start gap-1">
|
||||
<span className="text-[10px] text-muted-foreground/60 font-medium uppercase flex-shrink-0 mt-0.5">
|
||||
{match.role === 'user' ? 'U' : 'A'}
|
||||
</span>
|
||||
<HighlightedSnippet
|
||||
snippet={match.snippet}
|
||||
highlights={match.highlights}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null
|
||||
) : (
|
||||
<SidebarProjectList {...projectListProps} />
|
||||
)}
|
||||
<SidebarProjectList {...projectListProps} />
|
||||
</ScrollArea>
|
||||
|
||||
<SidebarFooter
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { Folder, FolderPlus, MessageSquare, Plus, RefreshCw, Search, X, PanelLeftClose } from 'lucide-react';
|
||||
import { FolderPlus, Plus, RefreshCw, Search, X, PanelLeftClose } from 'lucide-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import { Button, Input } from '../../../../shared/view/ui';
|
||||
import { IS_PLATFORM } from '../../../../constants/config';
|
||||
import { cn } from '../../../../lib/utils';
|
||||
|
||||
type SearchMode = 'projects' | 'conversations';
|
||||
|
||||
type SidebarHeaderProps = {
|
||||
isPWA: boolean;
|
||||
@@ -14,8 +11,6 @@ type SidebarHeaderProps = {
|
||||
searchFilter: string;
|
||||
onSearchFilterChange: (value: string) => void;
|
||||
onClearSearchFilter: () => void;
|
||||
searchMode: SearchMode;
|
||||
onSearchModeChange: (mode: SearchMode) => void;
|
||||
onRefresh: () => void;
|
||||
isRefreshing: boolean;
|
||||
onCreateProject: () => void;
|
||||
@@ -31,8 +26,6 @@ export default function SidebarHeader({
|
||||
searchFilter,
|
||||
onSearchFilterChange,
|
||||
onClearSearchFilter,
|
||||
searchMode,
|
||||
onSearchModeChange,
|
||||
onRefresh,
|
||||
isRefreshing,
|
||||
onCreateProject,
|
||||
@@ -108,55 +101,23 @@ export default function SidebarHeader({
|
||||
|
||||
{/* Search bar */}
|
||||
{projectsCount > 0 && !isLoading && (
|
||||
<div className="mt-2.5 space-y-2">
|
||||
{/* Search mode toggle */}
|
||||
<div className="flex rounded-lg bg-muted/50 p-0.5">
|
||||
<div className="relative mt-2.5">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground/50" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={t('projects.searchPlaceholder')}
|
||||
value={searchFilter}
|
||||
onChange={(event) => onSearchFilterChange(event.target.value)}
|
||||
className="nav-search-input h-9 rounded-xl border-0 pl-9 pr-8 text-sm transition-all duration-200 placeholder:text-muted-foreground/40 focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
/>
|
||||
{searchFilter && (
|
||||
<button
|
||||
onClick={() => onSearchModeChange('projects')}
|
||||
aria-pressed={searchMode === 'projects'}
|
||||
className={cn(
|
||||
"flex-1 flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium transition-all",
|
||||
searchMode === 'projects'
|
||||
? "bg-background shadow-sm text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
onClick={onClearSearchFilter}
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 rounded-md p-0.5 hover:bg-accent"
|
||||
>
|
||||
<Folder className="w-3 h-3" />
|
||||
{t('search.modeProjects')}
|
||||
<X className="h-3 w-3 text-muted-foreground" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onSearchModeChange('conversations')}
|
||||
aria-pressed={searchMode === 'conversations'}
|
||||
className={cn(
|
||||
"flex-1 flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium transition-all",
|
||||
searchMode === 'conversations'
|
||||
? "bg-background shadow-sm text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<MessageSquare className="w-3 h-3" />
|
||||
{t('search.modeConversations')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground/50 pointer-events-none" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={searchMode === 'conversations' ? t('search.conversationsPlaceholder') : t('projects.searchPlaceholder')}
|
||||
value={searchFilter}
|
||||
onChange={(event) => onSearchFilterChange(event.target.value)}
|
||||
className="nav-search-input pl-9 pr-8 h-9 text-sm rounded-xl border-0 placeholder:text-muted-foreground/40 focus-visible:ring-0 focus-visible:ring-offset-0 transition-all duration-200"
|
||||
/>
|
||||
{searchFilter && (
|
||||
<button
|
||||
onClick={onClearSearchFilter}
|
||||
aria-label={t('tooltips.clearSearch')}
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 p-0.5 hover:bg-accent rounded-md"
|
||||
>
|
||||
<X className="w-3 h-3 text-muted-foreground" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -201,54 +162,23 @@ export default function SidebarHeader({
|
||||
|
||||
{/* Mobile search */}
|
||||
{projectsCount > 0 && !isLoading && (
|
||||
<div className="mt-2.5 space-y-2">
|
||||
<div className="flex rounded-lg bg-muted/50 p-0.5">
|
||||
<div className="relative mt-2.5">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground/50" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={t('projects.searchPlaceholder')}
|
||||
value={searchFilter}
|
||||
onChange={(event) => onSearchFilterChange(event.target.value)}
|
||||
className="nav-search-input h-10 rounded-xl border-0 pl-10 pr-9 text-sm transition-all duration-200 placeholder:text-muted-foreground/40 focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
/>
|
||||
{searchFilter && (
|
||||
<button
|
||||
onClick={() => onSearchModeChange('projects')}
|
||||
aria-pressed={searchMode === 'projects'}
|
||||
className={cn(
|
||||
"flex-1 flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium transition-all",
|
||||
searchMode === 'projects'
|
||||
? "bg-background shadow-sm text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
onClick={onClearSearchFilter}
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 rounded-md p-1 hover:bg-accent"
|
||||
>
|
||||
<Folder className="w-3 h-3" />
|
||||
{t('search.modeProjects')}
|
||||
<X className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onSearchModeChange('conversations')}
|
||||
aria-pressed={searchMode === 'conversations'}
|
||||
className={cn(
|
||||
"flex-1 flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium transition-all",
|
||||
searchMode === 'conversations'
|
||||
? "bg-background shadow-sm text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<MessageSquare className="w-3 h-3" />
|
||||
{t('search.modeConversations')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground/50 pointer-events-none" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={searchMode === 'conversations' ? t('search.conversationsPlaceholder') : t('projects.searchPlaceholder')}
|
||||
value={searchFilter}
|
||||
onChange={(event) => onSearchFilterChange(event.target.value)}
|
||||
className="nav-search-input pl-10 pr-9 h-10 text-sm rounded-xl border-0 placeholder:text-muted-foreground/40 focus-visible:ring-0 focus-visible:ring-offset-0 transition-all duration-200"
|
||||
/>
|
||||
{searchFilter && (
|
||||
<button
|
||||
onClick={onClearSearchFilter}
|
||||
aria-label={t('tooltips.clearSearch')}
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 p-1 hover:bg-accent rounded-md"
|
||||
>
|
||||
<X className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -46,8 +46,7 @@
|
||||
"editSessionName": "Manually edit session name",
|
||||
"deleteSession": "Delete this session permanently",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"clearSearch": "Clear search"
|
||||
"cancel": "Cancel"
|
||||
},
|
||||
"navigation": {
|
||||
"chat": "Chat",
|
||||
@@ -104,18 +103,6 @@
|
||||
"version": {
|
||||
"updateAvailable": "Update available"
|
||||
},
|
||||
"search": {
|
||||
"modeProjects": "Projects",
|
||||
"modeConversations": "Conversations",
|
||||
"conversationsPlaceholder": "Search in conversations...",
|
||||
"searching": "Searching...",
|
||||
"noResults": "No results found",
|
||||
"tryDifferentQuery": "Try a different search query",
|
||||
"matches_one": "{{count}} match",
|
||||
"matches_other": "{{count}} matches",
|
||||
"projectsScanned_one": "{{count}} project scanned",
|
||||
"projectsScanned_other": "{{count}} projects scanned"
|
||||
},
|
||||
"deleteConfirmation": {
|
||||
"deleteProject": "Delete Project",
|
||||
"deleteSession": "Delete Session",
|
||||
|
||||
@@ -46,8 +46,7 @@
|
||||
"editSessionName": "手动编辑会话名称",
|
||||
"deleteSession": "永久删除此会话",
|
||||
"save": "保存",
|
||||
"cancel": "取消",
|
||||
"clearSearch": "清除搜索"
|
||||
"cancel": "取消"
|
||||
},
|
||||
"navigation": {
|
||||
"chat": "聊天",
|
||||
@@ -104,18 +103,6 @@
|
||||
"version": {
|
||||
"updateAvailable": "有可用更新"
|
||||
},
|
||||
"search": {
|
||||
"modeProjects": "项目",
|
||||
"modeConversations": "对话",
|
||||
"conversationsPlaceholder": "搜索对话内容...",
|
||||
"searching": "搜索中...",
|
||||
"noResults": "未找到结果",
|
||||
"tryDifferentQuery": "尝试不同的搜索词",
|
||||
"matches_one": "{{count}} 个匹配",
|
||||
"matches_other": "{{count}} 个匹配",
|
||||
"projectsScanned_one": "{{count}} 个项目已扫描",
|
||||
"projectsScanned_other": "{{count}} 个项目已扫描"
|
||||
},
|
||||
"deleteConfirmation": {
|
||||
"deleteProject": "删除项目",
|
||||
"deleteSession": "删除会话",
|
||||
|
||||
@@ -904,23 +904,4 @@
|
||||
summary svg[class*="transition-transform"] {
|
||||
transition: transform 200ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* Search result highlight flash */
|
||||
.search-highlight-flash {
|
||||
animation: search-flash 4s ease-out;
|
||||
}
|
||||
|
||||
@keyframes search-flash {
|
||||
0%, 50% {
|
||||
outline: 3px solid hsl(var(--primary));
|
||||
outline-offset: 4px;
|
||||
border-radius: 8px;
|
||||
background-color: hsl(var(--primary) / 0.06);
|
||||
}
|
||||
100% {
|
||||
outline: 3px solid transparent;
|
||||
outline-offset: 4px;
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
6
src/types/global.d.ts
vendored
6
src/types/global.d.ts
vendored
@@ -6,10 +6,4 @@ declare global {
|
||||
refreshProjects?: () => void | Promise<void>;
|
||||
openSettings?: (tab?: string) => void;
|
||||
}
|
||||
|
||||
interface EventSourceEventMap {
|
||||
result: MessageEvent;
|
||||
progress: MessageEvent;
|
||||
done: MessageEvent;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,12 +94,6 @@ export const api = {
|
||||
authenticatedFetch(`/api/projects/${projectName}${force ? '?force=true' : ''}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
searchConversationsUrl: (query, limit = 50) => {
|
||||
const token = localStorage.getItem('auth-token');
|
||||
const params = new URLSearchParams({ q: query, limit: String(limit) });
|
||||
if (token) params.set('token', token);
|
||||
return `/api/search/conversations?${params.toString()}`;
|
||||
},
|
||||
createProject: (path) =>
|
||||
authenticatedFetch('/api/projects/create', {
|
||||
method: 'POST',
|
||||
|
||||
Reference in New Issue
Block a user