Merge pull request #13 from 777genius/improvements
feat: enhance FAQ section in README and improve cross-platform handling
This commit is contained in:
commit
9319189ff9
34 changed files with 552 additions and 174 deletions
52
README.md
52
README.md
|
|
@ -60,6 +60,58 @@ A new approach to task management with AI agents.
|
||||||
---
|
---
|
||||||
-->
|
-->
|
||||||
|
|
||||||
|
## FAQ
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong>Do I need to install Claude Code before using this app?</strong></summary>
|
||||||
|
<br />
|
||||||
|
No. The app includes built-in installation and authentication — just launch and follow the setup wizard.
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong>Does it read or upload my code?</strong></summary>
|
||||||
|
<br />
|
||||||
|
No. Everything runs locally on your machine. The app reads Claude Code's session logs from <code>~/.claude/</code> — your source code is never sent anywhere.
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong>Can agents communicate with each other?</strong></summary>
|
||||||
|
<br />
|
||||||
|
Yes. Agents send direct messages, create shared tasks, and leave comments — all coordinated automatically through Claude Code's team protocol.
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong>Is it free?</strong></summary>
|
||||||
|
<br />
|
||||||
|
Yes, completely free and open source. The app itself requires no API keys or subscriptions. You only need a Claude Code plan from Anthropic to run agents.
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong>Can I review code changes before they're applied?</strong></summary>
|
||||||
|
<br />
|
||||||
|
Yes. Every task shows a full diff view where you can accept, reject, or comment on individual code hunks — similar to Cursor's review flow.
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong>What happens if an agent gets stuck?</strong></summary>
|
||||||
|
<br />
|
||||||
|
You can send a direct message to any agent at any time to course-correct, or stop and restart it from the process dashboard. If an agent needs your input, you'll get a notification and the task will be marked with a distinct badge on the board so you won't miss it.
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong>Can I use it just to view past sessions without running agents?</strong></summary>
|
||||||
|
<br />
|
||||||
|
Yes. The app works as a session viewer too — browse, search, and analyze any Claude Code session history.
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong>Does it support multiple projects and teams?</strong></summary>
|
||||||
|
<br />
|
||||||
|
Yes. Run multiple teams in one project or across different projects, even simultaneously. To avoid Git conflicts, ask agents to use git worktree in your provisioning prompt.
|
||||||
|
</details>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
No prerequisites — Claude Code can be installed and configured directly from the app.
|
No prerequisites — Claude Code can be installed and configured directly from the app.
|
||||||
|
|
|
||||||
|
|
@ -844,6 +844,9 @@ function createWindow(): void {
|
||||||
if (!mainWindow || mainWindow.isDestroyed()) return;
|
if (!mainWindow || mainWindow.isDestroyed()) return;
|
||||||
if (input.type !== 'keyDown') return;
|
if (input.type !== 'keyDown') return;
|
||||||
|
|
||||||
|
// Cmd on macOS, Ctrl on Windows/Linux — unified modifier for cross-platform shortcuts
|
||||||
|
const isMod = input.meta || input.control;
|
||||||
|
|
||||||
// Prevent Electron's default Ctrl+R / Cmd+R page reload so the renderer
|
// Prevent Electron's default Ctrl+R / Cmd+R page reload so the renderer
|
||||||
// keyboard handler can use it as "Refresh Session" (fixes #58).
|
// keyboard handler can use it as "Refresh Session" (fixes #58).
|
||||||
// Also prevent Ctrl+Shift+R / Cmd+Shift+R (hard reload).
|
// Also prevent Ctrl+Shift+R / Cmd+Shift+R (hard reload).
|
||||||
|
|
@ -852,14 +855,14 @@ function createWindow(): void {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prevent Cmd+N from opening new window; forward to renderer for review shortcuts
|
// Prevent Cmd+N / Ctrl+N from opening new window; forward to renderer for review shortcuts
|
||||||
if (input.meta && input.key.toLowerCase() === 'n') {
|
if (isMod && input.key.toLowerCase() === 'n') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
mainWindow.webContents.send('review:cmdN');
|
mainWindow.webContents.send('review:cmdN');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!input.meta) return;
|
if (!isMod) return;
|
||||||
|
|
||||||
const currentLevel = mainWindow.webContents.getZoomLevel();
|
const currentLevel = mainWindow.webContents.getZoomLevel();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -948,6 +948,16 @@ async function resolveWslHome(distro: string): Promise<string | null> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function resolveWslDefaultUser(distro: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const { stdout } = await runWsl(['-d', distro, '--', 'whoami'], 3000);
|
||||||
|
const user = stdout.trim();
|
||||||
|
return user && !user.includes('/') && !user.includes('\\') ? user : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handler for 'config:findWslClaudeRoots' - Find Windows UNC candidates for WSL Claude roots.
|
* Handler for 'config:findWslClaudeRoots' - Find Windows UNC candidates for WSL Claude roots.
|
||||||
*/
|
*/
|
||||||
|
|
@ -968,7 +978,14 @@ async function handleFindWslClaudeRoots(
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
for (const distro of distros) {
|
for (const distro of distros) {
|
||||||
const resolvedHomePath = await resolveWslHome(distro);
|
const resolvedHomePath = await resolveWslHome(distro);
|
||||||
const fallbackHomePath = process.env.USERNAME ? `/home/${process.env.USERNAME}` : null;
|
// Fallback: query the default WSL user, then try Windows USERNAME
|
||||||
|
const wslUser = await resolveWslDefaultUser(distro);
|
||||||
|
const fallbackUser = wslUser || process.env.USERNAME;
|
||||||
|
const fallbackHomePath = fallbackUser
|
||||||
|
? fallbackUser === 'root'
|
||||||
|
? '/root'
|
||||||
|
: `/home/${fallbackUser}`
|
||||||
|
: null;
|
||||||
const normalizedHome =
|
const normalizedHome =
|
||||||
normalizeWslHomePath(resolvedHomePath ?? '') ??
|
normalizeWslHomePath(resolvedHomePath ?? '') ??
|
||||||
(fallbackHomePath ? normalizeWslHomePath(fallbackHomePath) : null);
|
(fallbackHomePath ? normalizeWslHomePath(fallbackHomePath) : null);
|
||||||
|
|
|
||||||
|
|
@ -1764,11 +1764,13 @@ export function showTeamNativeNotification(opts: {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isMac = process.platform === 'darwin';
|
||||||
|
const truncatedBody = opts.body.slice(0, 300);
|
||||||
const iconPath = getAppIconPath();
|
const iconPath = getAppIconPath();
|
||||||
const notification = new Notification({
|
const notification = new Notification({
|
||||||
title: opts.title,
|
title: opts.title,
|
||||||
subtitle: opts.subtitle,
|
...(isMac && opts.subtitle ? { subtitle: opts.subtitle } : {}),
|
||||||
body: opts.body.slice(0, 300),
|
body: !isMac && opts.subtitle ? `${opts.subtitle}\n${truncatedBody}` : truncatedBody,
|
||||||
sound: config.notifications.soundEnabled ? 'default' : undefined,
|
sound: config.notifications.soundEnabled ? 'default' : undefined,
|
||||||
...(iconPath ? { icon: iconPath } : {}),
|
...(iconPath ? { icon: iconPath } : {}),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
*
|
*
|
||||||
* Responsibilities:
|
* Responsibilities:
|
||||||
* - Store error history at ~/.claude/claude-devtools-notifications.json (max 100 entries)
|
* - Store error history at ~/.claude/claude-devtools-notifications.json (max 100 entries)
|
||||||
* - Show native macOS notifications using Electron's Notification API
|
* - Show native notifications using Electron's Notification API (cross-platform)
|
||||||
* - Implement throttling (5 seconds per unique error hash)
|
* - Implement throttling (5 seconds per unique error hash)
|
||||||
* - Respect config.notifications.enabled and snoozedUntil
|
* - Respect config.notifications.enabled and snoozedUntil
|
||||||
* - Filter errors matching ignoredRegex patterns
|
* - Filter errors matching ignoredRegex patterns
|
||||||
|
|
@ -380,7 +380,9 @@ export class NotificationManager extends EventEmitter {
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shows a native macOS notification for an error.
|
* Shows a native notification for an error.
|
||||||
|
* Note: Electron's `subtitle` option only works on macOS.
|
||||||
|
* On Windows/Linux, we prepend the subtitle to the body instead.
|
||||||
*/
|
*/
|
||||||
private showNativeNotification(error: DetectedError): void {
|
private showNativeNotification(error: DetectedError): void {
|
||||||
// Guard against standalone/Docker mode where Electron's Notification API is unavailable
|
// Guard against standalone/Docker mode where Electron's Notification API is unavailable
|
||||||
|
|
@ -395,11 +397,13 @@ export class NotificationManager extends EventEmitter {
|
||||||
|
|
||||||
const config = this.configManager.getConfig();
|
const config = this.configManager.getConfig();
|
||||||
|
|
||||||
|
const isMac = process.platform === 'darwin';
|
||||||
|
const truncatedMessage = error.message.slice(0, 200);
|
||||||
const iconPath = getAppIconPath();
|
const iconPath = getAppIconPath();
|
||||||
const notification = new Notification({
|
const notification = new Notification({
|
||||||
title: 'Claude Code Error',
|
title: 'Claude Code Error',
|
||||||
subtitle: error.context.projectName,
|
...(isMac ? { subtitle: error.context.projectName } : {}),
|
||||||
body: error.message.slice(0, 200),
|
body: isMac ? truncatedMessage : `${error.context.projectName}\n${truncatedMessage}`,
|
||||||
sound: config.notifications.soundEnabled ? 'default' : undefined,
|
sound: config.notifications.soundEnabled ? 'default' : undefined,
|
||||||
...(iconPath ? { icon: iconPath } : {}),
|
...(iconPath ? { icon: iconPath } : {}),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -396,7 +396,7 @@ export class SshConnectionManager extends EventEmitter {
|
||||||
* Resolves authentication automatically by trying:
|
* Resolves authentication automatically by trying:
|
||||||
* 1. IdentityFile from SSH config
|
* 1. IdentityFile from SSH config
|
||||||
* 2. SSH agent
|
* 2. SSH agent
|
||||||
* 3. Default key files (id_ed25519, id_rsa)
|
* 3. Default key files (id_ed25519, id_rsa, id_ecdsa)
|
||||||
*/
|
*/
|
||||||
private async resolveAutoAuth(
|
private async resolveAutoAuth(
|
||||||
sshConfig: SshConfigHostEntry | null
|
sshConfig: SshConfigHostEntry | null
|
||||||
|
|
@ -407,10 +407,7 @@ export class SshConnectionManager extends EventEmitter {
|
||||||
if (resolved) {
|
if (resolved) {
|
||||||
// The config parser already told us there's an identity file.
|
// The config parser already told us there's an identity file.
|
||||||
// Try common identity file locations from config
|
// Try common identity file locations from config
|
||||||
const configKeyPaths = [
|
const configKeyPaths = this.getSshKeyPaths();
|
||||||
path.join(getHomeDir(), '.ssh', 'id_ed25519'),
|
|
||||||
path.join(getHomeDir(), '.ssh', 'id_rsa'),
|
|
||||||
];
|
|
||||||
for (const keyPath of configKeyPaths) {
|
for (const keyPath of configKeyPaths) {
|
||||||
try {
|
try {
|
||||||
const keyData = await fs.promises.readFile(keyPath, 'utf8');
|
const keyData = await fs.promises.readFile(keyPath, 'utf8');
|
||||||
|
|
@ -429,11 +426,7 @@ export class SshConnectionManager extends EventEmitter {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try default key files
|
// Try default key files
|
||||||
const defaultKeys = [
|
const defaultKeys = this.getSshKeyPaths();
|
||||||
path.join(getHomeDir(), '.ssh', 'id_ed25519'),
|
|
||||||
path.join(getHomeDir(), '.ssh', 'id_rsa'),
|
|
||||||
path.join(getHomeDir(), '.ssh', 'id_ecdsa'),
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const keyPath of defaultKeys) {
|
for (const keyPath of defaultKeys) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -447,6 +440,28 @@ export class SshConnectionManager extends EventEmitter {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns SSH key candidate paths for the current platform.
|
||||||
|
* On Windows, also checks %USERPROFILE%\.ssh\ (OpenSSH for Windows default).
|
||||||
|
*/
|
||||||
|
private getSshKeyPaths(): string[] {
|
||||||
|
const home = getHomeDir();
|
||||||
|
const keyNames = ['id_ed25519', 'id_rsa', 'id_ecdsa'];
|
||||||
|
const candidates = keyNames.map((name) => path.join(home, '.ssh', name));
|
||||||
|
|
||||||
|
// On Windows, USERPROFILE may differ from getHomeDir() when HOME is overridden
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
const userProfile = process.env.USERPROFILE;
|
||||||
|
if (userProfile && userProfile !== home) {
|
||||||
|
for (const name of keyNames) {
|
||||||
|
candidates.push(path.join(userProfile, '.ssh', name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidates;
|
||||||
|
}
|
||||||
|
|
||||||
private async resolveRemoteProjectsPath(username: string): Promise<string> {
|
private async resolveRemoteProjectsPath(username: string): Promise<string> {
|
||||||
// Prefer remote $HOME when available, then fall back to common paths.
|
// Prefer remote $HOME when available, then fall back to common paths.
|
||||||
const remoteHome = await this.resolveRemoteHomeDirectory();
|
const remoteHome = await this.resolveRemoteHomeDirectory();
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,10 @@ function expandWindowsBinaryNames(binaryName: string): string[] {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function collectNvmCandidates(): Promise<string[]> {
|
async function collectNvmCandidates(): Promise<string[]> {
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
return collectNvmWindowsCandidates();
|
||||||
|
}
|
||||||
|
|
||||||
const nvmNodeRoot = path.join(getHomeDir(), '.nvm', 'versions', 'node');
|
const nvmNodeRoot = path.join(getHomeDir(), '.nvm', 'versions', 'node');
|
||||||
let versions: string[];
|
let versions: string[];
|
||||||
try {
|
try {
|
||||||
|
|
@ -75,6 +79,28 @@ async function collectNvmCandidates(): Promise<string[]> {
|
||||||
.reverse();
|
.reverse();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collect NVM for Windows (nvm-windows) candidates.
|
||||||
|
* nvm-windows stores Node versions under %APPDATA%\nvm\<version>\.
|
||||||
|
*/
|
||||||
|
async function collectNvmWindowsCandidates(): Promise<string[]> {
|
||||||
|
const appdata = process.env.APPDATA;
|
||||||
|
if (!appdata) return [];
|
||||||
|
|
||||||
|
const nvmRoot = path.join(appdata, 'nvm');
|
||||||
|
let versions: string[];
|
||||||
|
try {
|
||||||
|
versions = await fs.promises.readdir(nvmRoot);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const exts = getWindowsExecutableExtensions();
|
||||||
|
return versions
|
||||||
|
.sort((a, b) => b.localeCompare(a, undefined, { numeric: true, sensitivity: 'base' }))
|
||||||
|
.flatMap((version) => exts.map((ext) => path.join(nvmRoot, version, `claude${ext}`)));
|
||||||
|
}
|
||||||
|
|
||||||
async function resolveFromPathEnv(binaryName: string): Promise<string | null> {
|
async function resolveFromPathEnv(binaryName: string): Promise<string | null> {
|
||||||
const rawPath = process.env.PATH;
|
const rawPath = process.env.PATH;
|
||||||
if (!rawPath) {
|
if (!rawPath) {
|
||||||
|
|
@ -176,22 +202,34 @@ export class ClaudeBinaryResolver {
|
||||||
const platformBinaryNames =
|
const platformBinaryNames =
|
||||||
process.platform === 'win32' ? expandWindowsBinaryNames(baseBinaryName) : [baseBinaryName];
|
process.platform === 'win32' ? expandWindowsBinaryNames(baseBinaryName) : [baseBinaryName];
|
||||||
|
|
||||||
const candidateDirs: string[] = [
|
const candidateDirs: string[] =
|
||||||
// Native binary installation path (claude install)
|
|
||||||
path.join(getHomeDir(), '.local', 'bin'),
|
|
||||||
path.join(getHomeDir(), '.npm-global', 'bin'),
|
|
||||||
path.join(getHomeDir(), '.npm', 'bin'),
|
|
||||||
process.platform === 'win32'
|
process.platform === 'win32'
|
||||||
? path.join(getHomeDir(), 'AppData', 'Roaming', 'npm')
|
? [
|
||||||
: '/usr/local/bin',
|
// Windows: npm global install
|
||||||
process.platform === 'win32' ? '' : '/opt/homebrew/bin',
|
path.join(getHomeDir(), 'AppData', 'Roaming', 'npm'),
|
||||||
].filter((candidate) => candidate.length > 0);
|
// Windows: scoop, chocolatey, and other package managers
|
||||||
|
path.join(getHomeDir(), 'scoop', 'shims'),
|
||||||
|
// Windows: Local programs
|
||||||
|
...(process.env.LOCALAPPDATA
|
||||||
|
? [path.join(process.env.LOCALAPPDATA, 'Programs', 'claude')]
|
||||||
|
: []),
|
||||||
|
// Windows: Program Files
|
||||||
|
...(process.env.ProgramFiles ? [path.join(process.env.ProgramFiles, 'claude')] : []),
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
// Unix: native binary installation path (claude install)
|
||||||
|
path.join(getHomeDir(), '.local', 'bin'),
|
||||||
|
path.join(getHomeDir(), '.npm-global', 'bin'),
|
||||||
|
path.join(getHomeDir(), '.npm', 'bin'),
|
||||||
|
'/usr/local/bin',
|
||||||
|
'/opt/homebrew/bin',
|
||||||
|
];
|
||||||
|
|
||||||
const candidates = candidateDirs.flatMap((dir) =>
|
const candidates = candidateDirs.flatMap((dir) =>
|
||||||
platformBinaryNames.map((name) => path.join(dir, name))
|
platformBinaryNames.map((name) => path.join(dir, name))
|
||||||
);
|
);
|
||||||
|
|
||||||
const nvmCandidates = process.platform === 'win32' ? [] : await collectNvmCandidates();
|
const nvmCandidates = await collectNvmCandidates();
|
||||||
const allCandidates = [...candidates, ...nvmCandidates];
|
const allCandidates = [...candidates, ...nvmCandidates];
|
||||||
|
|
||||||
// Check all fallback candidates in parallel for speed
|
// Check all fallback candidates in parallel for speed
|
||||||
|
|
|
||||||
|
|
@ -85,7 +85,12 @@ function parseArgs(argv) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function getHomeDir() {
|
function getHomeDir() {
|
||||||
return process.env.HOME || process.env.USERPROFILE || '';
|
if (process.env.HOME) return process.env.HOME;
|
||||||
|
if (process.env.USERPROFILE) return process.env.USERPROFILE;
|
||||||
|
if (process.env.HOMEDRIVE && process.env.HOMEPATH) {
|
||||||
|
return process.env.HOMEDRIVE + process.env.HOMEPATH;
|
||||||
|
}
|
||||||
|
try { return require('os').homedir(); } catch { return ''; }
|
||||||
}
|
}
|
||||||
|
|
||||||
function getClaudeDir(flags) {
|
function getClaudeDir(flags) {
|
||||||
|
|
@ -98,7 +103,7 @@ function getClaudeDir(flags) {
|
||||||
const inferred = inferClaudeDirFromScriptPath();
|
const inferred = inferClaudeDirFromScriptPath();
|
||||||
if (inferred) return inferred;
|
if (inferred) return inferred;
|
||||||
const home = getHomeDir();
|
const home = getHomeDir();
|
||||||
if (!home) die('HOME is not set');
|
if (!home) die('HOME/USERPROFILE is not set');
|
||||||
return path.join(home, '.claude');
|
return path.join(home, '.claude');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -148,7 +153,26 @@ function atomicWrite(filePath, data) {
|
||||||
'.' +
|
'.' +
|
||||||
String(Math.random().toString(16).slice(2));
|
String(Math.random().toString(16).slice(2));
|
||||||
fs.writeFileSync(tmp, data, 'utf8');
|
fs.writeFileSync(tmp, data, 'utf8');
|
||||||
fs.renameSync(tmp, filePath);
|
// On Windows, fs.renameSync can throw EPERM/EACCES when another process
|
||||||
|
// is concurrently renaming to the same target. Retry with backoff.
|
||||||
|
const maxRetries = process.platform === 'win32' ? 5 : 1;
|
||||||
|
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||||
|
try {
|
||||||
|
fs.renameSync(tmp, filePath);
|
||||||
|
return;
|
||||||
|
} catch (e) {
|
||||||
|
if (attempt < maxRetries - 1 && e && (e.code === 'EPERM' || e.code === 'EACCES')) {
|
||||||
|
// Busy wait — small random delay to reduce contention
|
||||||
|
const ms = Math.floor(Math.random() * 50) + 10;
|
||||||
|
const end = Date.now() + ms;
|
||||||
|
while (Date.now() < end) { /* spin */ }
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Clean up temp file on final failure
|
||||||
|
try { fs.unlinkSync(tmp); } catch { /* ignore */ }
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeStatus(value) {
|
function normalizeStatus(value) {
|
||||||
|
|
@ -976,8 +1000,8 @@ async function main() {
|
||||||
parts.push(
|
parts.push(
|
||||||
'\n' + ${JSON.stringify(AGENT_BLOCK_OPEN)},
|
'\n' + ${JSON.stringify(AGENT_BLOCK_OPEN)},
|
||||||
'Update task status using:',
|
'Update task status using:',
|
||||||
'node "$HOME/.claude/tools/${TOOL_FILE_NAME}" --team ' + String(teamName) + ' task start ' + String(task.id),
|
'node "' + __filename + '" --team ' + String(teamName) + ' task start ' + String(task.id),
|
||||||
'node "$HOME/.claude/tools/${TOOL_FILE_NAME}" --team ' + String(teamName) + ' task complete ' + String(task.id),
|
'node "' + __filename + '" --team ' + String(teamName) + ' task complete ' + String(task.id),
|
||||||
${JSON.stringify(AGENT_BLOCK_CLOSE)}
|
${JSON.stringify(AGENT_BLOCK_CLOSE)}
|
||||||
);
|
);
|
||||||
sendInboxMessage(paths, teamName, {
|
sendInboxMessage(paths, teamName, {
|
||||||
|
|
@ -1053,8 +1077,8 @@ async function main() {
|
||||||
parts.push(
|
parts.push(
|
||||||
'\n' + ${JSON.stringify(AGENT_BLOCK_OPEN)},
|
'\n' + ${JSON.stringify(AGENT_BLOCK_OPEN)},
|
||||||
'Update task status using:',
|
'Update task status using:',
|
||||||
'node "$HOME/.claude/tools/${TOOL_FILE_NAME}" --team ' + String(teamName) + ' task start ' + String(task.id),
|
'node "' + __filename + '" --team ' + String(teamName) + ' task start ' + String(task.id),
|
||||||
'node "$HOME/.claude/tools/${TOOL_FILE_NAME}" --team ' + String(teamName) + ' task complete ' + String(task.id),
|
'node "' + __filename + '" --team ' + String(teamName) + ' task complete ' + String(task.id),
|
||||||
${JSON.stringify(AGENT_BLOCK_CLOSE)}
|
${JSON.stringify(AGENT_BLOCK_CLOSE)}
|
||||||
);
|
);
|
||||||
sendInboxMessage(paths, teamName, {
|
sendInboxMessage(paths, teamName, {
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import {
|
||||||
getTeamsBasePath,
|
getTeamsBasePath,
|
||||||
} from '@main/utils/pathDecoder';
|
} from '@main/utils/pathDecoder';
|
||||||
import { isProcessAlive } from '@main/utils/processHealth';
|
import { isProcessAlive } from '@main/utils/processHealth';
|
||||||
|
import { killProcessByPid } from '@main/utils/processKill';
|
||||||
import { AGENT_BLOCK_CLOSE, AGENT_BLOCK_OPEN } from '@shared/constants/agentBlocks';
|
import { AGENT_BLOCK_CLOSE, AGENT_BLOCK_OPEN } from '@shared/constants/agentBlocks';
|
||||||
import { getMemberColor } from '@shared/constants/memberColors';
|
import { getMemberColor } from '@shared/constants/memberColors';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
|
|
@ -458,9 +459,9 @@ export class TeamDataService {
|
||||||
async killProcess(teamName: string, pid: number): Promise<void> {
|
async killProcess(teamName: string, pid: number): Promise<void> {
|
||||||
const processesPath = path.join(getTeamsBasePath(), teamName, 'processes.json');
|
const processesPath = path.join(getTeamsBasePath(), teamName, 'processes.json');
|
||||||
|
|
||||||
// Try to kill the process
|
// Try to kill the process (cross-platform: SIGTERM on Unix, taskkill on Windows)
|
||||||
try {
|
try {
|
||||||
process.kill(pid, 'SIGTERM');
|
killProcessByPid(pid);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
// ESRCH = process not found — still mark as stopped below
|
// ESRCH = process not found — still mark as stopped below
|
||||||
if (
|
if (
|
||||||
|
|
|
||||||
|
|
@ -2700,15 +2700,26 @@ export class TeamProvisioningService {
|
||||||
// getHomeDir() uses Electron's app.getPath('home') which handles Unicode
|
// getHomeDir() uses Electron's app.getPath('home') which handles Unicode
|
||||||
// correctly on Windows. Prefer it over process.env which may be garbled.
|
// correctly on Windows. Prefer it over process.env which may be garbled.
|
||||||
const electronHome = getHomeDir();
|
const electronHome = getHomeDir();
|
||||||
|
const isWindows = process.platform === 'win32';
|
||||||
const home = shellEnv.HOME?.trim() || electronHome;
|
const home = shellEnv.HOME?.trim() || electronHome;
|
||||||
const user = shellEnv.USER?.trim() || process.env.USER?.trim() || os.userInfo().username;
|
let osUsername = '';
|
||||||
const shell = shellEnv.SHELL?.trim() || process.env.SHELL?.trim() || '/bin/zsh';
|
try {
|
||||||
const xdgConfigHome =
|
osUsername = os.userInfo().username;
|
||||||
shellEnv.XDG_CONFIG_HOME?.trim() || process.env.XDG_CONFIG_HOME?.trim() || `${home}/.config`;
|
} catch {
|
||||||
const xdgStateHome =
|
// os.userInfo() can throw SystemError in restricted environments (no passwd entry, Docker, etc.)
|
||||||
shellEnv.XDG_STATE_HOME?.trim() ||
|
}
|
||||||
process.env.XDG_STATE_HOME?.trim() ||
|
const user =
|
||||||
`${home}/.local/state`;
|
shellEnv.USER?.trim() ||
|
||||||
|
process.env.USER?.trim() ||
|
||||||
|
process.env.USERNAME?.trim() ||
|
||||||
|
osUsername ||
|
||||||
|
'unknown';
|
||||||
|
|
||||||
|
// Shell: on Windows there is no SHELL env var; use COMSPEC (cmd.exe / powershell).
|
||||||
|
// On Unix, prefer the user's login shell from env or fall back to /bin/zsh.
|
||||||
|
const shell = isWindows
|
||||||
|
? (process.env.COMSPEC ?? 'powershell.exe')
|
||||||
|
: shellEnv.SHELL?.trim() || process.env.SHELL?.trim() || '/bin/zsh';
|
||||||
|
|
||||||
const env: NodeJS.ProcessEnv = {
|
const env: NodeJS.ProcessEnv = {
|
||||||
...process.env,
|
...process.env,
|
||||||
|
|
@ -2717,16 +2728,33 @@ export class TeamProvisioningService {
|
||||||
USERPROFILE: home,
|
USERPROFILE: home,
|
||||||
USER: user,
|
USER: user,
|
||||||
LOGNAME: shellEnv.LOGNAME?.trim() || process.env.LOGNAME?.trim() || user,
|
LOGNAME: shellEnv.LOGNAME?.trim() || process.env.LOGNAME?.trim() || user,
|
||||||
SHELL: shell,
|
|
||||||
TERM: shellEnv.TERM?.trim() || process.env.TERM?.trim() || 'xterm-256color',
|
TERM: shellEnv.TERM?.trim() || process.env.TERM?.trim() || 'xterm-256color',
|
||||||
XDG_CONFIG_HOME: xdgConfigHome,
|
|
||||||
XDG_STATE_HOME: xdgStateHome,
|
|
||||||
// Ensure CLI reads/writes from the same Claude root as the app.
|
// Ensure CLI reads/writes from the same Claude root as the app.
|
||||||
// This aligns teams/tasks locations when the app overrides claudeRootPath.
|
// This aligns teams/tasks locations when the app overrides claudeRootPath.
|
||||||
CLAUDE_CONFIG_DIR: getClaudeBasePath(),
|
CLAUDE_CONFIG_DIR: getClaudeBasePath(),
|
||||||
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1',
|
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// SHELL is a Unix concept — only set it on non-Windows platforms.
|
||||||
|
if (!isWindows) {
|
||||||
|
env.SHELL = shell;
|
||||||
|
}
|
||||||
|
|
||||||
|
// XDG directories are a freedesktop.org (Linux/macOS) convention.
|
||||||
|
// On Windows, these are unused by most tools and can cause confusion.
|
||||||
|
if (!isWindows) {
|
||||||
|
const xdgConfigHome =
|
||||||
|
shellEnv.XDG_CONFIG_HOME?.trim() ||
|
||||||
|
process.env.XDG_CONFIG_HOME?.trim() ||
|
||||||
|
`${home}/.config`;
|
||||||
|
const xdgStateHome =
|
||||||
|
shellEnv.XDG_STATE_HOME?.trim() ||
|
||||||
|
process.env.XDG_STATE_HOME?.trim() ||
|
||||||
|
`${home}/.local/state`;
|
||||||
|
env.XDG_CONFIG_HOME = xdgConfigHome;
|
||||||
|
env.XDG_STATE_HOME = xdgStateHome;
|
||||||
|
}
|
||||||
|
|
||||||
// 1. Explicit ANTHROPIC_API_KEY — works with `-p` mode directly
|
// 1. Explicit ANTHROPIC_API_KEY — works with `-p` mode directly
|
||||||
if (typeof env.ANTHROPIC_API_KEY === 'string' && env.ANTHROPIC_API_KEY.trim().length > 0) {
|
if (typeof env.ANTHROPIC_API_KEY === 'string' && env.ANTHROPIC_API_KEY.trim().length > 0) {
|
||||||
return { env, authSource: 'anthropic_api_key' };
|
return { env, authSource: 'anthropic_api_key' };
|
||||||
|
|
|
||||||
|
|
@ -176,8 +176,11 @@ async function shutdown(): Promise<void> {
|
||||||
// Signal Handlers
|
// Signal Handlers
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
process.on('SIGTERM', () => void shutdown());
|
// SIGINT works on all platforms (Ctrl+C), but SIGTERM does not exist on Windows.
|
||||||
process.on('SIGINT', () => void shutdown());
|
process.on('SIGINT', () => void shutdown());
|
||||||
|
if (process.platform !== 'win32') {
|
||||||
|
process.on('SIGTERM', () => void shutdown());
|
||||||
|
}
|
||||||
|
|
||||||
process.on('unhandledRejection', (reason) => {
|
process.on('unhandledRejection', (reason) => {
|
||||||
logger.error('Unhandled promise rejection:', reason);
|
logger.error('Unhandled promise rejection:', reason);
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,17 @@
|
||||||
|
/**
|
||||||
|
* Check whether a process with the given PID is still alive.
|
||||||
|
*
|
||||||
|
* Cross-platform notes:
|
||||||
|
* - `process.kill(pid, 0)` sends signal 0 (a no-op probe) on all platforms.
|
||||||
|
* On Windows, Node.js internally calls `OpenProcess()` which works correctly
|
||||||
|
* for same-user processes.
|
||||||
|
* - EPERM means the process exists but we lack permission to signal it —
|
||||||
|
* still counts as alive.
|
||||||
|
* - ESRCH (Unix) or ERROR_INVALID_PARAMETER (Windows, mapped to ESRCH by
|
||||||
|
* Node.js) means the process does not exist.
|
||||||
|
* - On Windows, zombie/defunct processes are not a concern because Windows
|
||||||
|
* cleans up process handles immediately upon exit (no Unix-style zombies).
|
||||||
|
*/
|
||||||
export function isProcessAlive(pid: number): boolean {
|
export function isProcessAlive(pid: number): boolean {
|
||||||
try {
|
try {
|
||||||
process.kill(pid, 0);
|
process.kill(pid, 0);
|
||||||
|
|
|
||||||
34
src/main/utils/processKill.ts
Normal file
34
src/main/utils/processKill.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
import { execFile } from 'child_process';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kill a process by PID in a cross-platform manner.
|
||||||
|
*
|
||||||
|
* On Unix: sends SIGTERM, which allows the process to handle the signal gracefully.
|
||||||
|
* On Windows: uses `taskkill /T /F /PID` to kill the entire process tree.
|
||||||
|
* - `process.kill(pid, 'SIGTERM')` on Windows does NOT actually send a signal —
|
||||||
|
* it calls TerminateProcess() which is equivalent to SIGKILL (immediate, ungraceful).
|
||||||
|
* - `taskkill /T` also kills child processes, preventing orphaned process trees.
|
||||||
|
*
|
||||||
|
* On Unix, throws if the process cannot be killed (except ESRCH — process already dead).
|
||||||
|
* On Windows, taskkill is best-effort (async fire-and-forget) to match killProcessTree() semantics.
|
||||||
|
*/
|
||||||
|
export function killProcessByPid(pid: number): void {
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
try {
|
||||||
|
const taskkillPath = path.join(
|
||||||
|
process.env.SystemRoot ?? 'C:\\Windows',
|
||||||
|
'System32',
|
||||||
|
'taskkill.exe'
|
||||||
|
);
|
||||||
|
execFile(taskkillPath, ['/T', '/F', '/PID', String(pid)], () => {
|
||||||
|
// Best-effort — ignore errors (process may have already exited)
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// taskkill failed to spawn, fall through to process.kill()
|
||||||
|
process.kill(pid, 'SIGTERM');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
process.kill(pid, 'SIGTERM');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -14,6 +14,14 @@ import { Minus, Square, X } from 'lucide-react';
|
||||||
|
|
||||||
const TITLE_BAR_HEIGHT = 32;
|
const TITLE_BAR_HEIGHT = 32;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect whether the custom title bar should be shown.
|
||||||
|
*
|
||||||
|
* In Electron, the userAgent string reliably contains the OS name
|
||||||
|
* (e.g. "Windows NT 10.0", "Linux x86_64"), so this check works on
|
||||||
|
* all three platforms. macOS is excluded because it uses native
|
||||||
|
* traffic-light window controls instead.
|
||||||
|
*/
|
||||||
function needsCustomTitleBar(): boolean {
|
function needsCustomTitleBar(): boolean {
|
||||||
if (!isElectronMode()) return false;
|
if (!isElectronMode()) return false;
|
||||||
const ua = window.navigator.userAgent;
|
const ua = window.navigator.userAgent;
|
||||||
|
|
|
||||||
|
|
@ -13,12 +13,13 @@ import {
|
||||||
} from '@renderer/components/ui/dialog';
|
} from '@renderer/components/ui/dialog';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||||
|
import { useBranchSync } from '@renderer/hooks/useBranchSync';
|
||||||
import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead';
|
import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead';
|
||||||
import { cn } from '@renderer/lib/utils';
|
import { cn } from '@renderer/lib/utils';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { createChipFromSelection } from '@renderer/utils/chipUtils';
|
import { createChipFromSelection } from '@renderer/utils/chipUtils';
|
||||||
import { formatProjectPath } from '@renderer/utils/pathDisplay';
|
import { formatProjectPath } from '@renderer/utils/pathDisplay';
|
||||||
import { buildTaskCountsByOwner } from '@renderer/utils/pathNormalize';
|
import { buildTaskCountsByOwner, normalizePath } from '@renderer/utils/pathNormalize';
|
||||||
import { nameColorSet } from '@renderer/utils/projectColor';
|
import { nameColorSet } from '@renderer/utils/projectColor';
|
||||||
import { resolveProjectIdByPath } from '@renderer/utils/projectLookup';
|
import { resolveProjectIdByPath } from '@renderer/utils/projectLookup';
|
||||||
import { toMessageKey } from '@renderer/utils/teamMessageKey';
|
import { toMessageKey } from '@renderer/utils/teamMessageKey';
|
||||||
|
|
@ -350,27 +351,16 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
};
|
};
|
||||||
}, [projectId]);
|
}, [projectId]);
|
||||||
|
|
||||||
// Resolve lead's git branch from project path
|
// Live git branch polling for the team's project path
|
||||||
const [leadBranch, setLeadBranch] = useState<string | null>(null);
|
const teamProjectPath = data?.config.projectPath?.trim() ?? null;
|
||||||
useEffect(() => {
|
const branchSyncPaths = useMemo(
|
||||||
const projectPath = data?.config.projectPath?.trim();
|
() => (teamProjectPath ? [teamProjectPath] : []),
|
||||||
if (!projectPath || typeof api.teams?.getProjectBranch !== 'function') {
|
[teamProjectPath]
|
||||||
setLeadBranch(null);
|
);
|
||||||
return;
|
useBranchSync(branchSyncPaths, { live: true });
|
||||||
}
|
const leadBranch = useStore((s) =>
|
||||||
let cancelled = false;
|
teamProjectPath ? (s.branchByPath[normalizePath(teamProjectPath)] ?? null) : null
|
||||||
void api.teams.getProjectBranch(projectPath).then(
|
);
|
||||||
(branch) => {
|
|
||||||
if (!cancelled) setLeadBranch(branch);
|
|
||||||
},
|
|
||||||
() => {
|
|
||||||
if (!cancelled) setLeadBranch(null);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [data?.config.projectPath]);
|
|
||||||
|
|
||||||
// Filter sessions to team-only using sessionHistory + leadSessionId
|
// Filter sessions to team-only using sessionHistory + leadSessionId
|
||||||
const teamSessions = useMemo(() => {
|
const teamSessions = useMemo(() => {
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import {
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from '@renderer/components/ui/tooltip';
|
} from '@renderer/components/ui/tooltip';
|
||||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||||
|
import { useBranchSync } from '@renderer/hooks/useBranchSync';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||||
import { buildTaskCountsByTeam, normalizePath } from '@renderer/utils/pathNormalize';
|
import { buildTaskCountsByTeam, normalizePath } from '@renderer/utils/pathNormalize';
|
||||||
|
|
@ -182,7 +183,6 @@ export const TeamListView = (): React.JSX.Element => {
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [filter, setFilter] = useState<TeamListFilterState>(EMPTY_TEAM_FILTER);
|
const [filter, setFilter] = useState<TeamListFilterState>(EMPTY_TEAM_FILTER);
|
||||||
const [aliveTeams, setAliveTeams] = useState<string[]>([]);
|
const [aliveTeams, setAliveTeams] = useState<string[]>([]);
|
||||||
const [branchByPath, setBranchByPath] = useState<Map<string, string | null>>(new Map());
|
|
||||||
const {
|
const {
|
||||||
teams,
|
teams,
|
||||||
teamsLoading,
|
teamsLoading,
|
||||||
|
|
@ -337,48 +337,13 @@ export const TeamListView = (): React.JSX.Element => {
|
||||||
leadActivityByTeam,
|
leadActivityByTeam,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Live branch/worktree for team project paths (poll so it updates during process)
|
// Fetch branches once for all visible team project paths (no live polling)
|
||||||
const projectPathsToPoll = useMemo(() => {
|
const teamPaths = useMemo(
|
||||||
const byKey = new Map<string, string>();
|
() => filteredTeams.map((t) => t.projectPath?.trim()).filter(Boolean) as string[],
|
||||||
for (const team of filteredTeams) {
|
[filteredTeams]
|
||||||
const p = team.projectPath?.trim();
|
);
|
||||||
if (p) {
|
useBranchSync(teamPaths, { live: false });
|
||||||
const key = normalizePath(p);
|
const branchByPath = useStore((s) => s.branchByPath);
|
||||||
if (!byKey.has(key)) byKey.set(key, p);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Array.from(byKey.entries());
|
|
||||||
}, [filteredTeams]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!electronMode || projectPathsToPoll.length === 0) return;
|
|
||||||
let cancelled = false;
|
|
||||||
const poll = async (): Promise<void> => {
|
|
||||||
const next = new Map<string, string | null>();
|
|
||||||
for (const [pathKey, actualPath] of projectPathsToPoll) {
|
|
||||||
if (cancelled) return;
|
|
||||||
try {
|
|
||||||
const branch = await api.teams.getProjectBranch(actualPath);
|
|
||||||
if (!cancelled) next.set(pathKey, branch);
|
|
||||||
} catch {
|
|
||||||
if (!cancelled) next.set(pathKey, null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!cancelled && next.size > 0) {
|
|
||||||
setBranchByPath((prev) => {
|
|
||||||
const m = new Map(prev);
|
|
||||||
for (const [k, v] of next) m.set(k, v);
|
|
||||||
return m;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
void poll();
|
|
||||||
const interval = setInterval(poll, 6000);
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
clearInterval(interval);
|
|
||||||
};
|
|
||||||
}, [electronMode, projectPathsToPoll]);
|
|
||||||
|
|
||||||
const restoreTeam = useStore((s) => s.restoreTeam);
|
const restoreTeam = useStore((s) => s.restoreTeam);
|
||||||
const permanentlyDeleteTeam = useStore((s) => s.permanentlyDeleteTeam);
|
const permanentlyDeleteTeam = useStore((s) => s.permanentlyDeleteTeam);
|
||||||
|
|
@ -748,7 +713,7 @@ export const TeamListView = (): React.JSX.Element => {
|
||||||
</p>
|
</p>
|
||||||
{team.projectPath &&
|
{team.projectPath &&
|
||||||
(() => {
|
(() => {
|
||||||
const branch = branchByPath.get(normalizePath(team.projectPath));
|
const branch = branchByPath[normalizePath(team.projectPath)];
|
||||||
if (!branch) return null;
|
if (!branch) return null;
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { getPreviewType, isPreviewable } from '@renderer/utils/previewRegistry';
|
import { getPreviewType, isPreviewable } from '@renderer/utils/previewRegistry';
|
||||||
|
import { getBasename } from '@shared/utils/platformPath';
|
||||||
|
|
||||||
import { EditorBinaryPlaceholder } from './EditorBinaryPlaceholder';
|
import { EditorBinaryPlaceholder } from './EditorBinaryPlaceholder';
|
||||||
import { EditorImagePreview } from './EditorImagePreview';
|
import { EditorImagePreview } from './EditorImagePreview';
|
||||||
|
|
@ -17,7 +18,7 @@ export const EditorBinaryState = ({
|
||||||
filePath,
|
filePath,
|
||||||
size,
|
size,
|
||||||
}: EditorBinaryStateProps): React.ReactElement => {
|
}: EditorBinaryStateProps): React.ReactElement => {
|
||||||
const fileName = filePath.split('/').pop() ?? filePath;
|
const fileName = getBasename(filePath) || filePath;
|
||||||
const previewType = getPreviewType(fileName);
|
const previewType = getPreviewType(fileName);
|
||||||
|
|
||||||
if (previewType === 'image' && isPreviewable(fileName, size)) {
|
if (previewType === 'image' && isPreviewable(fileName, size)) {
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
import { useCallback, useMemo } from 'react';
|
import { useCallback, useMemo } from 'react';
|
||||||
|
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
|
import { splitPath } from '@shared/utils/platformPath';
|
||||||
import { ChevronRight } from 'lucide-react';
|
import { ChevronRight } from 'lucide-react';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
|
|
@ -32,7 +33,7 @@ export const EditorBreadcrumb = (): React.ReactElement | null => {
|
||||||
? activeTabId.slice(projectPath.length + 1)
|
? activeTabId.slice(projectPath.length + 1)
|
||||||
: activeTabId;
|
: activeTabId;
|
||||||
|
|
||||||
return relativePath.split('/');
|
return splitPath(relativePath);
|
||||||
}, [activeTabId, projectPath]);
|
}, [activeTabId, projectPath]);
|
||||||
|
|
||||||
const handleSegmentClick = useCallback(
|
const handleSegmentClick = useCallback(
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
import React, { useCallback, useRef, useState } from 'react';
|
import React, { useCallback, useRef, useState } from 'react';
|
||||||
|
|
||||||
import * as ContextMenu from '@radix-ui/react-context-menu';
|
import * as ContextMenu from '@radix-ui/react-context-menu';
|
||||||
|
import { lastSeparatorIndex } from '@shared/utils/platformPath';
|
||||||
import {
|
import {
|
||||||
ClipboardCopy,
|
ClipboardCopy,
|
||||||
FilePlus,
|
FilePlus,
|
||||||
|
|
@ -84,7 +85,7 @@ export const EditorContextMenu = ({
|
||||||
const parentDir = target
|
const parentDir = target
|
||||||
? target.isDir
|
? target.isDir
|
||||||
? target.path
|
? target.path
|
||||||
: target.path.substring(0, target.path.lastIndexOf('/'))
|
: target.path.substring(0, lastSeparatorIndex(target.path))
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ import {
|
||||||
} from '@renderer/components/ui/dialog';
|
} from '@renderer/components/ui/dialog';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { sortTreeNodes } from '@renderer/utils/fileTreeBuilder';
|
import { sortTreeNodes } from '@renderer/utils/fileTreeBuilder';
|
||||||
|
import { getBasename, lastSeparatorIndex } from '@shared/utils/platformPath';
|
||||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
import { ChevronDown, ChevronRight, Folder, FolderOpen, Lock } from 'lucide-react';
|
import { ChevronDown, ChevronRight, Folder, FolderOpen, Lock } from 'lucide-react';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
@ -350,7 +351,7 @@ export const EditorFileTree = ({
|
||||||
const item = flatItemsByPath.get(overId);
|
const item = flatItemsByPath.get(overId);
|
||||||
if (item) {
|
if (item) {
|
||||||
const p = item.node.fullPath;
|
const p = item.node.fullPath;
|
||||||
targetDir = p.substring(0, p.lastIndexOf('/'));
|
targetDir = p.substring(0, lastSeparatorIndex(p));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -389,7 +390,7 @@ export const EditorFileTree = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
const destDir = dropTargetPath;
|
const destDir = dropTargetPath;
|
||||||
const sourceParent = sourcePath.substring(0, sourcePath.lastIndexOf('/'));
|
const sourceParent = sourcePath.substring(0, lastSeparatorIndex(sourcePath));
|
||||||
|
|
||||||
// Validation: same folder = no-op
|
// Validation: same folder = no-op
|
||||||
if (sourceParent === destDir) {
|
if (sourceParent === destDir) {
|
||||||
|
|
@ -543,7 +544,7 @@ export const EditorFileTree = ({
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="text-sm">Move to Trash</DialogTitle>
|
<DialogTitle className="text-sm">Move to Trash</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Move “{deleteConfirmPath?.split('/').pop() ?? ''}” to Trash?
|
Move “{deleteConfirmPath ? getBasename(deleteConfirmPath) : ''}” to Trash?
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import { useEditorKeyboardShortcuts } from '@renderer/hooks/useEditorKeyboardSho
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { buildFileAction, buildSelectionAction } from '@renderer/utils/buildSelectionAction';
|
import { buildFileAction, buildSelectionAction } from '@renderer/utils/buildSelectionAction';
|
||||||
import { shortcutLabel } from '@renderer/utils/platformKeys';
|
import { shortcutLabel } from '@renderer/utils/platformKeys';
|
||||||
|
import { getBasename, getDirname } from '@shared/utils/platformPath';
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
HelpCircle,
|
HelpCircle,
|
||||||
|
|
@ -216,7 +217,7 @@ export const ProjectEditorOverlay = ({
|
||||||
const result = await promise;
|
const result = await promise;
|
||||||
const ipcMs = performance.now() - t0;
|
const ipcMs = performance.now() - t0;
|
||||||
console.debug(
|
console.debug(
|
||||||
`[perf] loadFileContent: IPC=${ipcMs.toFixed(1)}ms, size=${result.size}, truncated=${result.truncated}, cached=${wasCached}, file=${filePath.split('/').pop() ?? ''}`
|
`[perf] loadFileContent: IPC=${ipcMs.toFixed(1)}ms, size=${result.size}, truncated=${result.truncated}, cached=${wasCached}, file=${getBasename(filePath)}`
|
||||||
);
|
);
|
||||||
setFileContent(result);
|
setFileContent(result);
|
||||||
|
|
||||||
|
|
@ -519,7 +520,7 @@ export const ProjectEditorOverlay = ({
|
||||||
onToggleMdPreview: isMarkdown ? toggleMdPreview : undefined,
|
onToggleMdPreview: isMarkdown ? toggleMdPreview : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
const projectName = projectPath.split('/').pop() ?? projectPath;
|
const projectName = getBasename(projectPath) || projectPath;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -785,7 +786,7 @@ export const ProjectEditorOverlay = ({
|
||||||
key={`${activeTabId}-${editorResetKey}`}
|
key={`${activeTabId}-${editorResetKey}`}
|
||||||
filePath={activeTabId}
|
filePath={activeTabId}
|
||||||
content={fileContent.content}
|
content={fileContent.content}
|
||||||
fileName={activeTabId.split('/').pop() ?? 'file'}
|
fileName={getBasename(activeTabId) || 'file'}
|
||||||
mtimeMs={fileContent.mtimeMs}
|
mtimeMs={fileContent.mtimeMs}
|
||||||
onCursorChange={handleCursorChange}
|
onCursorChange={handleCursorChange}
|
||||||
onDraftRecovered={handleDraftRecovered}
|
onDraftRecovered={handleDraftRecovered}
|
||||||
|
|
@ -803,7 +804,7 @@ export const ProjectEditorOverlay = ({
|
||||||
splitRatio={splitRatio}
|
splitRatio={splitRatio}
|
||||||
onSplitRatioChange={handleSplitRatioChange}
|
onSplitRatioChange={handleSplitRatioChange}
|
||||||
viewKey={activeTabId}
|
viewKey={activeTabId}
|
||||||
baseDir={activeTabId?.substring(0, activeTabId.lastIndexOf('/'))}
|
baseDir={activeTabId ? getDirname(activeTabId) : undefined}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { api } from '@renderer/api';
|
import { api } from '@renderer/api';
|
||||||
import { Button } from '@renderer/components/ui/button';
|
import { Button } from '@renderer/components/ui/button';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||||
|
import { getBasename, lastSeparatorIndex } from '@shared/utils/platformPath';
|
||||||
import { Loader2, Search, X } from 'lucide-react';
|
import { Loader2, Search, X } from 'lucide-react';
|
||||||
|
|
||||||
import { FileIcon } from './FileIcon';
|
import { FileIcon } from './FileIcon';
|
||||||
|
|
@ -263,10 +264,9 @@ const SearchFileGroup = ({
|
||||||
query,
|
query,
|
||||||
caseSensitive,
|
caseSensitive,
|
||||||
}: SearchFileGroupProps): React.ReactElement => {
|
}: SearchFileGroupProps): React.ReactElement => {
|
||||||
const fileName = relativePath.split('/').pop() ?? relativePath;
|
const fileName = getBasename(relativePath) || relativePath;
|
||||||
const dirPath = relativePath.includes('/')
|
const sepIdx = lastSeparatorIndex(relativePath);
|
||||||
? relativePath.slice(0, relativePath.lastIndexOf('/'))
|
const dirPath = sepIdx >= 0 ? relativePath.slice(0, sepIdx) : '';
|
||||||
: '';
|
|
||||||
return (
|
return (
|
||||||
<div className="border-border/50 border-b">
|
<div className="border-border/50 border-b">
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { useEffect, useState } from 'react';
|
||||||
import { api } from '@renderer/api';
|
import { api } from '@renderer/api';
|
||||||
import { cn } from '@renderer/lib/utils';
|
import { cn } from '@renderer/lib/utils';
|
||||||
import { formatRelativeTime } from '@renderer/utils/formatters';
|
import { formatRelativeTime } from '@renderer/utils/formatters';
|
||||||
|
import { getBasename } from '@shared/utils/platformPath';
|
||||||
import { formatTokensCompact } from '@shared/utils/tokenFormatting';
|
import { formatTokensCompact } from '@shared/utils/tokenFormatting';
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
|
|
@ -247,7 +248,7 @@ const FilesTouchedSection = ({
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
{visibleFiles.map((filePath) => {
|
{visibleFiles.map((filePath) => {
|
||||||
const basename = filePath.split('/').pop() ?? filePath;
|
const basename = getBasename(filePath) || filePath;
|
||||||
const fStats = fileStats?.[filePath];
|
const fStats = fileStats?.[filePath];
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
128
src/renderer/hooks/useBranchSync.ts
Normal file
128
src/renderer/hooks/useBranchSync.ts
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
/**
|
||||||
|
* Centralized git branch polling hook.
|
||||||
|
*
|
||||||
|
* Provides two modes:
|
||||||
|
* - `live: false` (default) — one-shot fetch on mount / path change
|
||||||
|
* - `live: true` — continuous polling with ref-counted shared timer
|
||||||
|
*
|
||||||
|
* Data is stored in the Zustand store (`branchByPath`) so any component
|
||||||
|
* can read it via `useStore(s => s.branchByPath)`.
|
||||||
|
*
|
||||||
|
* The module-level polling manager guarantees:
|
||||||
|
* - A single shared `setInterval` across all live subscribers
|
||||||
|
* - Deduplication: N components subscribing to the same path = 1 poll
|
||||||
|
* - Automatic cleanup: timer stops when all subscribers unmount
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useMemo } from 'react';
|
||||||
|
|
||||||
|
import { useStore } from '@renderer/store';
|
||||||
|
import { normalizePath } from '@renderer/utils/pathNormalize';
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Constants
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
const POLL_INTERVAL_MS = 6_000;
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Module-level polling manager (singleton, outside React lifecycle)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
const livePaths = new Map<string, { actualPath: string; refCount: number }>();
|
||||||
|
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
function startPollingIfNeeded(): void {
|
||||||
|
if (pollTimer || livePaths.size === 0) return;
|
||||||
|
pollTimer = setInterval(() => {
|
||||||
|
const paths = Array.from(livePaths.values()).map((v) => v.actualPath);
|
||||||
|
void useStore.getState().fetchBranches(paths);
|
||||||
|
}, POLL_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopPollingIfEmpty(): void {
|
||||||
|
if (pollTimer && livePaths.size === 0) {
|
||||||
|
clearInterval(pollTimer);
|
||||||
|
pollTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function subscribe(normalizedKey: string, actualPath: string): void {
|
||||||
|
const entry = livePaths.get(normalizedKey);
|
||||||
|
if (entry) {
|
||||||
|
entry.refCount++;
|
||||||
|
} else {
|
||||||
|
livePaths.set(normalizedKey, { actualPath, refCount: 1 });
|
||||||
|
}
|
||||||
|
startPollingIfNeeded();
|
||||||
|
}
|
||||||
|
|
||||||
|
function unsubscribe(normalizedKey: string): void {
|
||||||
|
const entry = livePaths.get(normalizedKey);
|
||||||
|
if (!entry) return;
|
||||||
|
entry.refCount--;
|
||||||
|
if (entry.refCount <= 0) {
|
||||||
|
livePaths.delete(normalizedKey);
|
||||||
|
}
|
||||||
|
stopPollingIfEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Hook
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync git branch data for the given project paths into the store.
|
||||||
|
*
|
||||||
|
* @param paths - Raw project paths to resolve branches for
|
||||||
|
* @param options.live - When true, keeps polling every 6s while mounted
|
||||||
|
*/
|
||||||
|
export function useBranchSync(paths: string[], options?: { live?: boolean }): void {
|
||||||
|
const live = options?.live ?? false;
|
||||||
|
const fetchBranches = useStore((s) => s.fetchBranches);
|
||||||
|
|
||||||
|
// Deduplicate and normalize paths into [normalizedKey, actualPath] entries.
|
||||||
|
// `paths` identity should be stabilized by the caller via useMemo.
|
||||||
|
const pathEntries = useMemo(() => {
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
for (const p of paths) {
|
||||||
|
const trimmed = p.trim();
|
||||||
|
if (trimmed) {
|
||||||
|
const key = normalizePath(trimmed);
|
||||||
|
if (!map.has(key)) map.set(key, trimmed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(map.entries());
|
||||||
|
}, [paths]);
|
||||||
|
|
||||||
|
// Stable string key for useEffect deps — avoids re-running on same set of paths
|
||||||
|
const pathsKey = useMemo(
|
||||||
|
() =>
|
||||||
|
pathEntries
|
||||||
|
.map(([k]) => k)
|
||||||
|
.sort((a, b) => a.localeCompare(b))
|
||||||
|
.join('\n'),
|
||||||
|
[pathEntries]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Initial fetch on mount and whenever paths change (both live and one-shot modes)
|
||||||
|
useEffect(() => {
|
||||||
|
if (pathEntries.length === 0) return;
|
||||||
|
void fetchBranches(pathEntries.map(([, actual]) => actual));
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- pathsKey is a stable string derived from pathEntries, avoids re-fetching on array identity change
|
||||||
|
}, [pathsKey, fetchBranches]);
|
||||||
|
|
||||||
|
// Live subscription: register paths with the ref-counted polling manager
|
||||||
|
useEffect(() => {
|
||||||
|
if (!live || pathEntries.length === 0) return;
|
||||||
|
for (const [key, actual] of pathEntries) {
|
||||||
|
subscribe(key, actual);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
for (const [key] of pathEntries) {
|
||||||
|
unsubscribe(key);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- pathsKey is a stable string key; pathEntries excluded to prevent re-subscribing on array identity change
|
||||||
|
}, [live, pathsKey]);
|
||||||
|
}
|
||||||
|
|
@ -13,6 +13,7 @@ import { editorBridge } from '@renderer/utils/editorBridge';
|
||||||
import { invalidateQuickOpenCache } from '@renderer/utils/quickOpenCache';
|
import { invalidateQuickOpenCache } from '@renderer/utils/quickOpenCache';
|
||||||
import { computeDisambiguatedTabs } from '@renderer/utils/tabLabelDisambiguation';
|
import { computeDisambiguatedTabs } from '@renderer/utils/tabLabelDisambiguation';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
|
import { getBasename, lastSeparatorIndex, splitPath } from '@shared/utils/platformPath';
|
||||||
|
|
||||||
import type { AppState } from '../types';
|
import type { AppState } from '../types';
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -330,7 +331,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
if (relative) {
|
if (relative) {
|
||||||
const segments = relative.split('/');
|
const segments = splitPath(relative);
|
||||||
// Expand each parent directory sequentially (root → child → grandchild).
|
// Expand each parent directory sequentially (root → child → grandchild).
|
||||||
// Skip the last segment (the file name itself).
|
// Skip the last segment (the file name itself).
|
||||||
// Each expandDirectory call is awaited so that its children are merged
|
// Each expandDirectory call is awaited so that its children are merged
|
||||||
|
|
@ -577,7 +578,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fileName = filePath.split('/').pop() ?? 'file';
|
const fileName = getBasename(filePath) || 'file';
|
||||||
const language = getLanguageFromFileName(fileName);
|
const language = getLanguageFromFileName(fileName);
|
||||||
|
|
||||||
const tab: EditorFileTab = {
|
const tab: EditorFileTab = {
|
||||||
|
|
@ -881,7 +882,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh parent directory
|
// Refresh parent directory
|
||||||
const parentDir = filePath.substring(0, filePath.lastIndexOf('/'));
|
const parentDir = filePath.substring(0, lastSeparatorIndex(filePath));
|
||||||
if (parentDir) {
|
if (parentDir) {
|
||||||
await refreshDirectory(get, set, parentDir);
|
await refreshDirectory(get, set, parentDir);
|
||||||
}
|
}
|
||||||
|
|
@ -906,7 +907,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
|
||||||
try {
|
try {
|
||||||
const result = await api.editor.moveFile(sourcePath, destDir);
|
const result = await api.editor.moveFile(sourcePath, destDir);
|
||||||
const { newPath, isDirectory } = result;
|
const { newPath, isDirectory } = result;
|
||||||
const oldParent = sourcePath.substring(0, sourcePath.lastIndexOf('/'));
|
const oldParent = sourcePath.substring(0, lastSeparatorIndex(sourcePath));
|
||||||
|
|
||||||
// Record move timestamps for watcher cooldown
|
// Record move timestamps for watcher cooldown
|
||||||
recentMoveTimestamps.set(sourcePath, Date.now());
|
recentMoveTimestamps.set(sourcePath, Date.now());
|
||||||
|
|
@ -917,7 +918,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
|
||||||
const tabs = s.editorOpenTabs.map((tab) => {
|
const tabs = s.editorOpenTabs.map((tab) => {
|
||||||
const remapped = remapPath(tab.filePath, sourcePath, newPath);
|
const remapped = remapPath(tab.filePath, sourcePath, newPath);
|
||||||
if (remapped === tab.filePath) return tab;
|
if (remapped === tab.filePath) return tab;
|
||||||
const fileName = remapped.split('/').pop() ?? 'file';
|
const fileName = getBasename(remapped) || 'file';
|
||||||
return {
|
return {
|
||||||
...tab,
|
...tab,
|
||||||
id: remapped,
|
id: remapped,
|
||||||
|
|
@ -1009,7 +1010,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
|
||||||
try {
|
try {
|
||||||
const result = await api.editor.renameFile(sourcePath, newName);
|
const result = await api.editor.renameFile(sourcePath, newName);
|
||||||
const { newPath, isDirectory } = result;
|
const { newPath, isDirectory } = result;
|
||||||
const parentDir = sourcePath.substring(0, sourcePath.lastIndexOf('/'));
|
const parentDir = sourcePath.substring(0, lastSeparatorIndex(sourcePath));
|
||||||
|
|
||||||
recentMoveTimestamps.set(sourcePath, Date.now());
|
recentMoveTimestamps.set(sourcePath, Date.now());
|
||||||
recentMoveTimestamps.set(newPath, Date.now());
|
recentMoveTimestamps.set(newPath, Date.now());
|
||||||
|
|
@ -1018,7 +1019,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
|
||||||
const tabs = s.editorOpenTabs.map((tab) => {
|
const tabs = s.editorOpenTabs.map((tab) => {
|
||||||
const remapped = remapPath(tab.filePath, sourcePath, newPath);
|
const remapped = remapPath(tab.filePath, sourcePath, newPath);
|
||||||
if (remapped === tab.filePath) return tab;
|
if (remapped === tab.filePath) return tab;
|
||||||
const fileName = remapped.split('/').pop() ?? 'file';
|
const fileName = getBasename(remapped) || 'file';
|
||||||
return {
|
return {
|
||||||
...tab,
|
...tab,
|
||||||
id: remapped,
|
id: remapped,
|
||||||
|
|
@ -1208,7 +1209,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
|
||||||
// Refresh parent directory in tree for create/delete
|
// Refresh parent directory in tree for create/delete
|
||||||
if (event.type === 'create' || event.type === 'delete') {
|
if (event.type === 'create' || event.type === 'delete') {
|
||||||
invalidateQuickOpenCache();
|
invalidateQuickOpenCache();
|
||||||
const parentDir = event.path.substring(0, event.path.lastIndexOf('/'));
|
const parentDir = event.path.substring(0, lastSeparatorIndex(event.path));
|
||||||
if (parentDir && editorProjectPath) {
|
if (parentDir && editorProjectPath) {
|
||||||
const existing = dirRefreshDebounceTimers.get(parentDir);
|
const existing = dirRefreshDebounceTimers.get(parentDir);
|
||||||
if (existing) clearTimeout(existing);
|
if (existing) clearTimeout(existing);
|
||||||
|
|
@ -1295,7 +1296,7 @@ async function refreshDirectory(
|
||||||
const t0 = performance.now();
|
const t0 = performance.now();
|
||||||
const result = await api.editor.readDir(dirPath);
|
const result = await api.editor.readDir(dirPath);
|
||||||
log.info(
|
log.info(
|
||||||
`[perf] refreshDirectory: IPC=${(performance.now() - t0).toFixed(1)}ms, entries=${result.entries.length}, dir=${dirPath.split('/').pop() ?? ''}`
|
`[perf] refreshDirectory: IPC=${(performance.now() - t0).toFixed(1)}ms, entries=${result.entries.length}, dir=${getBasename(dirPath)}`
|
||||||
);
|
);
|
||||||
const currentTree = get().editorFileTree;
|
const currentTree = get().editorFileTree;
|
||||||
if (!currentTree) return;
|
if (!currentTree) return;
|
||||||
|
|
|
||||||
|
|
@ -116,6 +116,8 @@ export interface TeamSlice {
|
||||||
teamByName: Record<string, TeamSummary>;
|
teamByName: Record<string, TeamSummary>;
|
||||||
/** O(1) lookup: sessionId -> owning team (lead + history) */
|
/** O(1) lookup: sessionId -> owning team (lead + history) */
|
||||||
teamBySessionId: Record<string, TeamSummary>;
|
teamBySessionId: Record<string, TeamSummary>;
|
||||||
|
/** Centralized git branch cache: normalizedPath → branch name | null */
|
||||||
|
branchByPath: Record<string, string | null>;
|
||||||
teamsLoading: boolean;
|
teamsLoading: boolean;
|
||||||
teamsError: string | null;
|
teamsError: string | null;
|
||||||
globalTasks: GlobalTask[];
|
globalTasks: GlobalTask[];
|
||||||
|
|
@ -141,6 +143,7 @@ export interface TeamSlice {
|
||||||
provisioningError: string | null;
|
provisioningError: string | null;
|
||||||
kanbanFilterQuery: string | null;
|
kanbanFilterQuery: string | null;
|
||||||
provisioningProgressUnsubscribe: (() => void) | null;
|
provisioningProgressUnsubscribe: (() => void) | null;
|
||||||
|
fetchBranches: (paths: string[]) => Promise<void>;
|
||||||
fetchTeams: () => Promise<void>;
|
fetchTeams: () => Promise<void>;
|
||||||
fetchAllTasks: () => Promise<void>;
|
fetchAllTasks: () => Promise<void>;
|
||||||
openTeamsTab: () => void;
|
openTeamsTab: () => void;
|
||||||
|
|
@ -213,6 +216,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
teams: [],
|
teams: [],
|
||||||
teamByName: {},
|
teamByName: {},
|
||||||
teamBySessionId: {},
|
teamBySessionId: {},
|
||||||
|
branchByPath: {},
|
||||||
teamsLoading: false,
|
teamsLoading: false,
|
||||||
teamsError: null,
|
teamsError: null,
|
||||||
globalTasks: [],
|
globalTasks: [],
|
||||||
|
|
@ -244,6 +248,21 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
deletedTasks: [],
|
deletedTasks: [],
|
||||||
deletedTasksLoading: false,
|
deletedTasksLoading: false,
|
||||||
|
|
||||||
|
fetchBranches: async (paths: string[]) => {
|
||||||
|
const results: Record<string, string | null> = {};
|
||||||
|
for (const p of paths) {
|
||||||
|
try {
|
||||||
|
const branch = await api.teams.getProjectBranch(p);
|
||||||
|
results[normalizePath(p)] = branch;
|
||||||
|
} catch {
|
||||||
|
results[normalizePath(p)] = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Object.keys(results).length > 0) {
|
||||||
|
set((state) => ({ branchByPath: { ...state.branchByPath, ...results } }));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
fetchTeams: async () => {
|
fetchTeams: async () => {
|
||||||
// Guard: prevent concurrent fetches (component mount + centralized init chain).
|
// Guard: prevent concurrent fetches (component mount + centralized init chain).
|
||||||
// Only effective during initial load (when teamsLoading is set to true below).
|
// Only effective during initial load (when teamsLoading is set to true below).
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@
|
||||||
* without pulling in CodeMirror dependencies.
|
* without pulling in CodeMirror dependencies.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { getBasename } from '@shared/utils/platformPath';
|
||||||
|
|
||||||
import type { EditorSelectionAction, EditorSelectionInfo } from '@shared/types/editor';
|
import type { EditorSelectionAction, EditorSelectionInfo } from '@shared/types/editor';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -66,7 +68,7 @@ export function buildFileAction(
|
||||||
filePath: string,
|
filePath: string,
|
||||||
projectPath?: string | null
|
projectPath?: string | null
|
||||||
): EditorSelectionAction {
|
): EditorSelectionAction {
|
||||||
const fileName = filePath.split('/').pop() ?? 'file';
|
const fileName = getBasename(filePath) || 'file';
|
||||||
const displayPath =
|
const displayPath =
|
||||||
projectPath && filePath.startsWith(projectPath + '/')
|
projectPath && filePath.startsWith(projectPath + '/')
|
||||||
? filePath.slice(projectPath.length + 1)
|
? filePath.slice(projectPath.length + 1)
|
||||||
|
|
@ -87,7 +89,7 @@ export function buildSelectionAction(
|
||||||
type: EditorSelectionAction['type'],
|
type: EditorSelectionAction['type'],
|
||||||
info: EditorSelectionInfo
|
info: EditorSelectionInfo
|
||||||
): EditorSelectionAction {
|
): EditorSelectionAction {
|
||||||
const fileName = info.filePath.split('/').pop() ?? 'file';
|
const fileName = getBasename(info.filePath) || 'file';
|
||||||
const lang = getCodeFenceLanguage(fileName);
|
const lang = getCodeFenceLanguage(fileName);
|
||||||
const lineRef =
|
const lineRef =
|
||||||
info.fromLine === info.toLine
|
info.fromLine === info.toLine
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
|
|
||||||
import { chipToken } from '@renderer/types/inlineChip';
|
import { chipToken } from '@renderer/types/inlineChip';
|
||||||
import { getCodeFenceLanguage } from '@renderer/utils/buildSelectionAction';
|
import { getCodeFenceLanguage } from '@renderer/utils/buildSelectionAction';
|
||||||
|
import { getBasename } from '@shared/utils/platformPath';
|
||||||
|
|
||||||
import type { InlineChip } from '@renderer/types/inlineChip';
|
import type { InlineChip } from '@renderer/types/inlineChip';
|
||||||
import type { EditorSelectionAction } from '@shared/types/editor';
|
import type { EditorSelectionAction } from '@shared/types/editor';
|
||||||
|
|
@ -31,7 +32,7 @@ export function createChipFromSelection(
|
||||||
);
|
);
|
||||||
if (isDuplicate) return null;
|
if (isDuplicate) return null;
|
||||||
|
|
||||||
const fileName = action.filePath.split('/').pop() ?? 'file';
|
const fileName = getBasename(action.filePath) || 'file';
|
||||||
return {
|
return {
|
||||||
id: `chip-${++chipCounter}-${Date.now()}`,
|
id: `chip-${++chipCounter}-${Date.now()}`,
|
||||||
filePath: action.filePath,
|
filePath: action.filePath,
|
||||||
|
|
@ -51,7 +52,7 @@ export function createChipFromSelection(
|
||||||
);
|
);
|
||||||
if (isDuplicate) return null;
|
if (isDuplicate) return null;
|
||||||
|
|
||||||
const fileName = action.filePath.split('/').pop() ?? 'file';
|
const fileName = getBasename(action.filePath) || 'file';
|
||||||
const language = getCodeFenceLanguage(fileName);
|
const language = getCodeFenceLanguage(fileName);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,11 @@
|
||||||
* - Directory-specific CLAUDE.md files (detected from Read tool calls and @ mentions)
|
* - Directory-specific CLAUDE.md files (detected from Read tool calls and @ mentions)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
lastSeparatorIndex,
|
||||||
|
splitPath as splitPathCrossPlatform,
|
||||||
|
} from '@shared/utils/platformPath';
|
||||||
|
|
||||||
import { extractFileReferences } from './groupTransformer';
|
import { extractFileReferences } from './groupTransformer';
|
||||||
|
|
||||||
import type { ClaudeMdInjection, ClaudeMdSource, ClaudeMdStats } from '../types/claudeMd';
|
import type { ClaudeMdInjection, ClaudeMdSource, ClaudeMdStats } from '../types/claudeMd';
|
||||||
|
|
@ -147,26 +152,9 @@ function normalizeSeparators(input: string, separator: '/' | '\\'): string {
|
||||||
return output;
|
return output;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Local alias — delegates to the shared cross-platform splitPath. */
|
||||||
function splitPath(input: string): string[] {
|
function splitPath(input: string): string[] {
|
||||||
const parts: string[] = [];
|
return splitPathCrossPlatform(input);
|
||||||
let current = '';
|
|
||||||
|
|
||||||
for (const char of input) {
|
|
||||||
if (char === '/' || char === '\\') {
|
|
||||||
if (current.length > 0) {
|
|
||||||
parts.push(current);
|
|
||||||
current = '';
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
current += char;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (current.length > 0) {
|
|
||||||
parts.push(current);
|
|
||||||
}
|
|
||||||
|
|
||||||
return parts;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeForComparison(input: string): string {
|
function normalizeForComparison(input: string): string {
|
||||||
|
|
@ -177,7 +165,7 @@ function normalizeForComparison(input: string): string {
|
||||||
* Get the directory containing a file.
|
* Get the directory containing a file.
|
||||||
*/
|
*/
|
||||||
export function getDirectory(filePath: string): string {
|
export function getDirectory(filePath: string): string {
|
||||||
const lastSep = Math.max(filePath.lastIndexOf('/'), filePath.lastIndexOf('\\'));
|
const lastSep = lastSeparatorIndex(filePath);
|
||||||
if (lastSep === -1) return '';
|
if (lastSep === -1) return '';
|
||||||
return filePath.slice(0, lastSep);
|
return filePath.slice(0, lastSep);
|
||||||
}
|
}
|
||||||
|
|
@ -186,7 +174,7 @@ export function getDirectory(filePath: string): string {
|
||||||
* Get the parent directory of a path.
|
* Get the parent directory of a path.
|
||||||
*/
|
*/
|
||||||
export function getParentDirectory(dirPath: string): string | null {
|
export function getParentDirectory(dirPath: string): string | null {
|
||||||
const lastSep = Math.max(dirPath.lastIndexOf('/'), dirPath.lastIndexOf('\\'));
|
const lastSep = lastSeparatorIndex(dirPath);
|
||||||
if (lastSep <= 0) return null; // At root or invalid
|
if (lastSep <= 0) return null; // At root or invalid
|
||||||
return dirPath.slice(0, lastSep);
|
return dirPath.slice(0, lastSep);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@
|
||||||
* Used by ReviewFileTree (FileChangeSummary) and EditorFileTree (FileTreeEntry).
|
* Used by ReviewFileTree (FileChangeSummary) and EditorFileTree (FileTreeEntry).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { splitPath as splitPathCrossPlatform } from '@shared/utils/platformPath';
|
||||||
|
|
||||||
export interface TreeNode<T> {
|
export interface TreeNode<T> {
|
||||||
name: string;
|
name: string;
|
||||||
fullPath: string;
|
fullPath: string;
|
||||||
|
|
@ -28,7 +30,7 @@ export function buildTree<T>(
|
||||||
const root: TreeNode<T> = { name: '', fullPath: '', isFile: false, children: [] };
|
const root: TreeNode<T> = { name: '', fullPath: '', isFile: false, children: [] };
|
||||||
|
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const parts = getPath(item).split('/');
|
const parts = splitPathCrossPlatform(getPath(item));
|
||||||
let current = root;
|
let current = root;
|
||||||
|
|
||||||
for (let i = 0; i < parts.length; i++) {
|
for (let i = 0; i < parts.length; i++) {
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@
|
||||||
* Also provides resolveAbsolutePath() for clipboard copy (~ → real home, relative → absolute).
|
* Also provides resolveAbsolutePath() for clipboard copy (~ → real home, relative → absolute).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { splitPath } from '@shared/utils/platformPath';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shorten a file path for display in compact UI elements.
|
* Shorten a file path for display in compact UI elements.
|
||||||
* Full path should still be available via tooltip (title attribute).
|
* Full path should still be available via tooltip (title attribute).
|
||||||
|
|
@ -82,7 +84,7 @@ export function formatProjectPath(path: string): string {
|
||||||
const p = path.replace(/\\/g, '/');
|
const p = path.replace(/\\/g, '/');
|
||||||
|
|
||||||
if (p.startsWith('/Users/') || p.startsWith('/home/')) {
|
if (p.startsWith('/Users/') || p.startsWith('/home/')) {
|
||||||
const parts = p.split('/').filter(Boolean);
|
const parts = splitPath(p);
|
||||||
if (parts.length >= 2) {
|
if (parts.length >= 2) {
|
||||||
const rest = parts.slice(2).join('/');
|
const rest = parts.slice(2).join('/');
|
||||||
return rest ? `~/${rest}` : '~';
|
return rest ? `~/${rest}` : '~';
|
||||||
|
|
@ -90,7 +92,7 @@ export function formatProjectPath(path: string): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isWindowsUserPath(path)) {
|
if (isWindowsUserPath(path)) {
|
||||||
const parts = p.split('/').filter(Boolean);
|
const parts = splitPath(p);
|
||||||
if (parts.length >= 3) {
|
if (parts.length >= 3) {
|
||||||
const rest = parts.slice(3).join('/');
|
const rest = parts.slice(3).join('/');
|
||||||
return rest ? `~/${rest}` : '~';
|
return rest ? `~/${rest}` : '~';
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@
|
||||||
* 4. Unique file names get no label (disambiguatedLabel = undefined)
|
* 4. Unique file names get no label (disambiguatedLabel = undefined)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { splitPath } from '@shared/utils/platformPath';
|
||||||
|
|
||||||
import type { EditorFileTab } from '@shared/types/editor';
|
import type { EditorFileTab } from '@shared/types/editor';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -49,7 +51,7 @@ export function computeDisambiguatedTabs(tabs: EditorFileTab[]): EditorFileTab[]
|
||||||
|
|
||||||
// Split paths into segments for comparison
|
// Split paths into segments for comparison
|
||||||
const pathSegments = group.map((tab) => {
|
const pathSegments = group.map((tab) => {
|
||||||
const parts = tab.filePath.split('/');
|
const parts = splitPath(tab.filePath);
|
||||||
// Remove the file name (last segment)
|
// Remove the file name (last segment)
|
||||||
parts.pop();
|
parts.pop();
|
||||||
return parts;
|
return parts;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { normalizePath } from '@renderer/utils/pathNormalize';
|
import { normalizePath } from '@renderer/utils/pathNormalize';
|
||||||
|
import { splitPath } from '@shared/utils/platformPath';
|
||||||
import { differenceInDays, isToday, isYesterday } from 'date-fns';
|
import { differenceInDays, isToday, isYesterday } from 'date-fns';
|
||||||
|
|
||||||
import { DATE_CATEGORY_ORDER } from '../types/tabs';
|
import { DATE_CATEGORY_ORDER } from '../types/tabs';
|
||||||
|
|
@ -101,10 +102,7 @@ function trimTrailingPathSep(p: string): string {
|
||||||
|
|
||||||
export function projectLabelFromPath(path: string): string {
|
export function projectLabelFromPath(path: string): string {
|
||||||
const normalized = trimTrailingPathSep(path);
|
const normalized = trimTrailingPathSep(path);
|
||||||
const segments = normalized
|
const segments = splitPath(normalized);
|
||||||
.split('/')
|
|
||||||
.flatMap((s) => s.split('\\'))
|
|
||||||
.filter(Boolean);
|
|
||||||
return segments.length > 0 ? segments[segments.length - 1] : path || NO_PROJECT_LABEL;
|
return segments.length > 0 ? segments[segments.length - 1] : path || NO_PROJECT_LABEL;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
31
src/shared/utils/platformPath.ts
Normal file
31
src/shared/utils/platformPath.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
/**
|
||||||
|
* Cross-platform path utilities for the renderer process.
|
||||||
|
*
|
||||||
|
* Node's `path` module is unavailable in the renderer, and incoming paths
|
||||||
|
* may originate from any OS (Unix `/` or Windows `\`). Every helper here
|
||||||
|
* handles both separators transparently.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const SEP_RE = /[/\\]/;
|
||||||
|
|
||||||
|
/** Split a file path on both `/` and `\` separators. */
|
||||||
|
export function splitPath(filePath: string): string[] {
|
||||||
|
return filePath.split(SEP_RE).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get the last segment (filename) from a path. */
|
||||||
|
export function getBasename(filePath: string): string {
|
||||||
|
const parts = splitPath(filePath);
|
||||||
|
return parts[parts.length - 1] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get directory part of a path (everything before the last separator). */
|
||||||
|
export function getDirname(filePath: string): string {
|
||||||
|
const lastSep = lastSeparatorIndex(filePath);
|
||||||
|
return lastSep === -1 ? '' : filePath.substring(0, lastSep);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Find the last path separator index (handles both `/` and `\`). */
|
||||||
|
export function lastSeparatorIndex(filePath: string): number {
|
||||||
|
return Math.max(filePath.lastIndexOf('/'), filePath.lastIndexOf('\\'));
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue