feat: add FAQ section to README and improve cross-platform handling in code
- Introduced a comprehensive FAQ section in the README to address common user queries regarding app installation, code handling, agent communication, and project management. - Enhanced cross-platform keyboard shortcut handling in the Electron app for better user experience on macOS and Windows/Linux. - Updated signal handling in the standalone process to ensure proper shutdown behavior across platforms. - Improved WSL user resolution logic to support default user retrieval for better compatibility. - Enhanced notification handling to support cross-platform features and improve user feedback. - Refactored SSH connection management to include additional key file types and improve authentication handling. - Updated team management services to ensure consistent process termination across platforms. - Improved project path handling in team provisioning to accommodate different operating systems. - Enhanced editor components to utilize shared utility functions for path management, improving code maintainability.
This commit is contained in:
parent
64b9dc526d
commit
52ef9fd0a8
34 changed files with 521 additions and 173 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
|
||||
|
||||
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 (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
|
||||
// keyboard handler can use it as "Refresh Session" (fixes #58).
|
||||
// Also prevent Ctrl+Shift+R / Cmd+Shift+R (hard reload).
|
||||
|
|
@ -852,14 +855,14 @@ function createWindow(): void {
|
|||
return;
|
||||
}
|
||||
|
||||
// Prevent Cmd+N from opening new window; forward to renderer for review shortcuts
|
||||
if (input.meta && input.key.toLowerCase() === 'n') {
|
||||
// Prevent Cmd+N / Ctrl+N from opening new window; forward to renderer for review shortcuts
|
||||
if (isMod && input.key.toLowerCase() === 'n') {
|
||||
event.preventDefault();
|
||||
mainWindow.webContents.send('review:cmdN');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!input.meta) return;
|
||||
if (!isMod) return;
|
||||
|
||||
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.
|
||||
*/
|
||||
|
|
@ -968,7 +978,10 @@ async function handleFindWslClaudeRoots(
|
|||
const seen = new Set<string>();
|
||||
for (const distro of distros) {
|
||||
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 ? `/home/${fallbackUser}` : null;
|
||||
const normalizedHome =
|
||||
normalizeWslHomePath(resolvedHomePath ?? '') ??
|
||||
(fallbackHomePath ? normalizeWslHomePath(fallbackHomePath) : null);
|
||||
|
|
|
|||
|
|
@ -1764,11 +1764,13 @@ export function showTeamNativeNotification(opts: {
|
|||
return;
|
||||
}
|
||||
|
||||
const isMac = process.platform === 'darwin';
|
||||
const truncatedBody = opts.body.slice(0, 300);
|
||||
const iconPath = getAppIconPath();
|
||||
const notification = new Notification({
|
||||
title: opts.title,
|
||||
subtitle: opts.subtitle,
|
||||
body: opts.body.slice(0, 300),
|
||||
...(isMac && opts.subtitle ? { subtitle: opts.subtitle } : {}),
|
||||
body: !isMac && opts.subtitle ? `${opts.subtitle}\n${truncatedBody}` : truncatedBody,
|
||||
sound: config.notifications.soundEnabled ? 'default' : undefined,
|
||||
...(iconPath ? { icon: iconPath } : {}),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
*
|
||||
* Responsibilities:
|
||||
* - 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)
|
||||
* - Respect config.notifications.enabled and snoozedUntil
|
||||
* - 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 {
|
||||
// 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 isMac = process.platform === 'darwin';
|
||||
const truncatedMessage = error.message.slice(0, 200);
|
||||
const iconPath = getAppIconPath();
|
||||
const notification = new Notification({
|
||||
title: 'Claude Code Error',
|
||||
subtitle: error.context.projectName,
|
||||
body: error.message.slice(0, 200),
|
||||
...(isMac ? { subtitle: error.context.projectName } : {}),
|
||||
body: isMac ? truncatedMessage : `${error.context.projectName}\n${truncatedMessage}`,
|
||||
sound: config.notifications.soundEnabled ? 'default' : undefined,
|
||||
...(iconPath ? { icon: iconPath } : {}),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -396,7 +396,7 @@ export class SshConnectionManager extends EventEmitter {
|
|||
* Resolves authentication automatically by trying:
|
||||
* 1. IdentityFile from SSH config
|
||||
* 2. SSH agent
|
||||
* 3. Default key files (id_ed25519, id_rsa)
|
||||
* 3. Default key files (id_ed25519, id_rsa, id_ecdsa)
|
||||
*/
|
||||
private async resolveAutoAuth(
|
||||
sshConfig: SshConfigHostEntry | null
|
||||
|
|
@ -407,10 +407,7 @@ export class SshConnectionManager extends EventEmitter {
|
|||
if (resolved) {
|
||||
// The config parser already told us there's an identity file.
|
||||
// Try common identity file locations from config
|
||||
const configKeyPaths = [
|
||||
path.join(getHomeDir(), '.ssh', 'id_ed25519'),
|
||||
path.join(getHomeDir(), '.ssh', 'id_rsa'),
|
||||
];
|
||||
const configKeyPaths = this.getSshKeyPaths();
|
||||
for (const keyPath of configKeyPaths) {
|
||||
try {
|
||||
const keyData = await fs.promises.readFile(keyPath, 'utf8');
|
||||
|
|
@ -429,11 +426,7 @@ export class SshConnectionManager extends EventEmitter {
|
|||
}
|
||||
|
||||
// Try default key files
|
||||
const defaultKeys = [
|
||||
path.join(getHomeDir(), '.ssh', 'id_ed25519'),
|
||||
path.join(getHomeDir(), '.ssh', 'id_rsa'),
|
||||
path.join(getHomeDir(), '.ssh', 'id_ecdsa'),
|
||||
];
|
||||
const defaultKeys = this.getSshKeyPaths();
|
||||
|
||||
for (const keyPath of defaultKeys) {
|
||||
try {
|
||||
|
|
@ -447,6 +440,28 @@ export class SshConnectionManager extends EventEmitter {
|
|||
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> {
|
||||
// Prefer remote $HOME when available, then fall back to common paths.
|
||||
const remoteHome = await this.resolveRemoteHomeDirectory();
|
||||
|
|
|
|||
|
|
@ -61,6 +61,10 @@ function expandWindowsBinaryNames(binaryName: string): string[] {
|
|||
}
|
||||
|
||||
async function collectNvmCandidates(): Promise<string[]> {
|
||||
if (process.platform === 'win32') {
|
||||
return collectNvmWindowsCandidates();
|
||||
}
|
||||
|
||||
const nvmNodeRoot = path.join(getHomeDir(), '.nvm', 'versions', 'node');
|
||||
let versions: string[];
|
||||
try {
|
||||
|
|
@ -75,6 +79,29 @@ async function collectNvmCandidates(): Promise<string[]> {
|
|||
.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
|
||||
.flatMap((version) => exts.map((ext) => path.join(nvmRoot, version, `claude${ext}`)))
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
.reverse();
|
||||
}
|
||||
|
||||
async function resolveFromPathEnv(binaryName: string): Promise<string | null> {
|
||||
const rawPath = process.env.PATH;
|
||||
if (!rawPath) {
|
||||
|
|
@ -176,22 +203,34 @@ export class ClaudeBinaryResolver {
|
|||
const platformBinaryNames =
|
||||
process.platform === 'win32' ? expandWindowsBinaryNames(baseBinaryName) : [baseBinaryName];
|
||||
|
||||
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'),
|
||||
const candidateDirs: string[] =
|
||||
process.platform === 'win32'
|
||||
? path.join(getHomeDir(), 'AppData', 'Roaming', 'npm')
|
||||
: '/usr/local/bin',
|
||||
process.platform === 'win32' ? '' : '/opt/homebrew/bin',
|
||||
].filter((candidate) => candidate.length > 0);
|
||||
? [
|
||||
// Windows: npm global install
|
||||
path.join(getHomeDir(), 'AppData', 'Roaming', 'npm'),
|
||||
// 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) =>
|
||||
platformBinaryNames.map((name) => path.join(dir, name))
|
||||
);
|
||||
|
||||
const nvmCandidates = process.platform === 'win32' ? [] : await collectNvmCandidates();
|
||||
const nvmCandidates = await collectNvmCandidates();
|
||||
const allCandidates = [...candidates, ...nvmCandidates];
|
||||
|
||||
// Check all fallback candidates in parallel for speed
|
||||
|
|
|
|||
|
|
@ -85,7 +85,12 @@ function parseArgs(argv) {
|
|||
}
|
||||
|
||||
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) {
|
||||
|
|
@ -98,7 +103,7 @@ function getClaudeDir(flags) {
|
|||
const inferred = inferClaudeDirFromScriptPath();
|
||||
if (inferred) return inferred;
|
||||
const home = getHomeDir();
|
||||
if (!home) die('HOME is not set');
|
||||
if (!home) die('HOME/USERPROFILE is not set');
|
||||
return path.join(home, '.claude');
|
||||
}
|
||||
|
||||
|
|
@ -976,8 +981,8 @@ async function main() {
|
|||
parts.push(
|
||||
'\n' + ${JSON.stringify(AGENT_BLOCK_OPEN)},
|
||||
'Update task status using:',
|
||||
'node "$HOME/.claude/tools/${TOOL_FILE_NAME}" --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 start ' + String(task.id),
|
||||
'node "' + __filename + '" --team ' + String(teamName) + ' task complete ' + String(task.id),
|
||||
${JSON.stringify(AGENT_BLOCK_CLOSE)}
|
||||
);
|
||||
sendInboxMessage(paths, teamName, {
|
||||
|
|
@ -1053,8 +1058,8 @@ async function main() {
|
|||
parts.push(
|
||||
'\n' + ${JSON.stringify(AGENT_BLOCK_OPEN)},
|
||||
'Update task status using:',
|
||||
'node "$HOME/.claude/tools/${TOOL_FILE_NAME}" --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 start ' + String(task.id),
|
||||
'node "' + __filename + '" --team ' + String(teamName) + ' task complete ' + String(task.id),
|
||||
${JSON.stringify(AGENT_BLOCK_CLOSE)}
|
||||
);
|
||||
sendInboxMessage(paths, teamName, {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
getTeamsBasePath,
|
||||
} from '@main/utils/pathDecoder';
|
||||
import { isProcessAlive } from '@main/utils/processHealth';
|
||||
import { killProcessByPid } from '@main/utils/processKill';
|
||||
import { AGENT_BLOCK_CLOSE, AGENT_BLOCK_OPEN } from '@shared/constants/agentBlocks';
|
||||
import { getMemberColor } from '@shared/constants/memberColors';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
|
|
@ -458,9 +459,9 @@ export class TeamDataService {
|
|||
async killProcess(teamName: string, pid: number): Promise<void> {
|
||||
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 {
|
||||
process.kill(pid, 'SIGTERM');
|
||||
killProcessByPid(pid);
|
||||
} catch (err: unknown) {
|
||||
// ESRCH = process not found — still mark as stopped below
|
||||
if (
|
||||
|
|
|
|||
|
|
@ -2700,15 +2700,19 @@ export class TeamProvisioningService {
|
|||
// getHomeDir() uses Electron's app.getPath('home') which handles Unicode
|
||||
// correctly on Windows. Prefer it over process.env which may be garbled.
|
||||
const electronHome = getHomeDir();
|
||||
const isWindows = process.platform === 'win32';
|
||||
const home = shellEnv.HOME?.trim() || electronHome;
|
||||
const user = shellEnv.USER?.trim() || process.env.USER?.trim() || os.userInfo().username;
|
||||
const shell = shellEnv.SHELL?.trim() || process.env.SHELL?.trim() || '/bin/zsh';
|
||||
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`;
|
||||
const user =
|
||||
shellEnv.USER?.trim() ||
|
||||
process.env.USER?.trim() ||
|
||||
process.env.USERNAME?.trim() ||
|
||||
os.userInfo().username;
|
||||
|
||||
// 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 = {
|
||||
...process.env,
|
||||
|
|
@ -2717,16 +2721,33 @@ export class TeamProvisioningService {
|
|||
USERPROFILE: home,
|
||||
USER: user,
|
||||
LOGNAME: shellEnv.LOGNAME?.trim() || process.env.LOGNAME?.trim() || user,
|
||||
SHELL: shell,
|
||||
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.
|
||||
// This aligns teams/tasks locations when the app overrides claudeRootPath.
|
||||
CLAUDE_CONFIG_DIR: getClaudeBasePath(),
|
||||
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
|
||||
if (typeof env.ANTHROPIC_API_KEY === 'string' && env.ANTHROPIC_API_KEY.trim().length > 0) {
|
||||
return { env, authSource: 'anthropic_api_key' };
|
||||
|
|
|
|||
|
|
@ -176,8 +176,11 @@ async function shutdown(): Promise<void> {
|
|||
// 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());
|
||||
if (process.platform !== 'win32') {
|
||||
process.on('SIGTERM', () => void shutdown());
|
||||
}
|
||||
|
||||
process.on('unhandledRejection', (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 {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
|
|
|
|||
33
src/main/utils/processKill.ts
Normal file
33
src/main/utils/processKill.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
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.
|
||||
*
|
||||
* Throws if the process cannot be killed (except ESRCH — process already dead).
|
||||
*/
|
||||
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;
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
if (!isElectronMode()) return false;
|
||||
const ua = window.navigator.userAgent;
|
||||
|
|
|
|||
|
|
@ -13,12 +13,13 @@ import {
|
|||
} from '@renderer/components/ui/dialog';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||
import { useBranchSync } from '@renderer/hooks/useBranchSync';
|
||||
import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead';
|
||||
import { cn } from '@renderer/lib/utils';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { createChipFromSelection } from '@renderer/utils/chipUtils';
|
||||
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 { resolveProjectIdByPath } from '@renderer/utils/projectLookup';
|
||||
import { toMessageKey } from '@renderer/utils/teamMessageKey';
|
||||
|
|
@ -350,27 +351,16 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
};
|
||||
}, [projectId]);
|
||||
|
||||
// Resolve lead's git branch from project path
|
||||
const [leadBranch, setLeadBranch] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
const projectPath = data?.config.projectPath?.trim();
|
||||
if (!projectPath || typeof api.teams?.getProjectBranch !== 'function') {
|
||||
setLeadBranch(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void api.teams.getProjectBranch(projectPath).then(
|
||||
(branch) => {
|
||||
if (!cancelled) setLeadBranch(branch);
|
||||
},
|
||||
() => {
|
||||
if (!cancelled) setLeadBranch(null);
|
||||
}
|
||||
);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [data?.config.projectPath]);
|
||||
// Live git branch polling for the team's project path
|
||||
const teamProjectPath = data?.config.projectPath?.trim() ?? null;
|
||||
const branchSyncPaths = useMemo(
|
||||
() => (teamProjectPath ? [teamProjectPath] : []),
|
||||
[teamProjectPath]
|
||||
);
|
||||
useBranchSync(branchSyncPaths, { live: true });
|
||||
const leadBranch = useStore((s) =>
|
||||
teamProjectPath ? (s.branchByPath[normalizePath(teamProjectPath)] ?? null) : null
|
||||
);
|
||||
|
||||
// Filter sessions to team-only using sessionHistory + leadSessionId
|
||||
const teamSessions = useMemo(() => {
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
TooltipTrigger,
|
||||
} from '@renderer/components/ui/tooltip';
|
||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||
import { useBranchSync } from '@renderer/hooks/useBranchSync';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||
import { buildTaskCountsByTeam, normalizePath } from '@renderer/utils/pathNormalize';
|
||||
|
|
@ -182,7 +183,6 @@ export const TeamListView = (): React.JSX.Element => {
|
|||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [filter, setFilter] = useState<TeamListFilterState>(EMPTY_TEAM_FILTER);
|
||||
const [aliveTeams, setAliveTeams] = useState<string[]>([]);
|
||||
const [branchByPath, setBranchByPath] = useState<Map<string, string | null>>(new Map());
|
||||
const {
|
||||
teams,
|
||||
teamsLoading,
|
||||
|
|
@ -337,48 +337,13 @@ export const TeamListView = (): React.JSX.Element => {
|
|||
leadActivityByTeam,
|
||||
]);
|
||||
|
||||
// Live branch/worktree for team project paths (poll so it updates during process)
|
||||
const projectPathsToPoll = useMemo(() => {
|
||||
const byKey = new Map<string, string>();
|
||||
for (const team of filteredTeams) {
|
||||
const p = team.projectPath?.trim();
|
||||
if (p) {
|
||||
const key = normalizePath(p);
|
||||
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]);
|
||||
// Fetch branches once for all visible team project paths (no live polling)
|
||||
const teamPaths = useMemo(
|
||||
() => filteredTeams.map((t) => t.projectPath?.trim()).filter(Boolean) as string[],
|
||||
[filteredTeams]
|
||||
);
|
||||
useBranchSync(teamPaths, { live: false });
|
||||
const branchByPath = useStore((s) => s.branchByPath);
|
||||
|
||||
const restoreTeam = useStore((s) => s.restoreTeam);
|
||||
const permanentlyDeleteTeam = useStore((s) => s.permanentlyDeleteTeam);
|
||||
|
|
@ -748,7 +713,7 @@ export const TeamListView = (): React.JSX.Element => {
|
|||
</p>
|
||||
{team.projectPath &&
|
||||
(() => {
|
||||
const branch = branchByPath.get(normalizePath(team.projectPath));
|
||||
const branch = branchByPath[normalizePath(team.projectPath)];
|
||||
if (!branch) return null;
|
||||
return (
|
||||
<span
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
*/
|
||||
|
||||
import { getPreviewType, isPreviewable } from '@renderer/utils/previewRegistry';
|
||||
import { getBasename } from '@shared/utils/platformPath';
|
||||
|
||||
import { EditorBinaryPlaceholder } from './EditorBinaryPlaceholder';
|
||||
import { EditorImagePreview } from './EditorImagePreview';
|
||||
|
|
@ -17,7 +18,7 @@ export const EditorBinaryState = ({
|
|||
filePath,
|
||||
size,
|
||||
}: EditorBinaryStateProps): React.ReactElement => {
|
||||
const fileName = filePath.split('/').pop() ?? filePath;
|
||||
const fileName = getBasename(filePath) || filePath;
|
||||
const previewType = getPreviewType(fileName);
|
||||
|
||||
if (previewType === 'image' && isPreviewable(fileName, size)) {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
import { useStore } from '@renderer/store';
|
||||
import { splitPath } from '@shared/utils/platformPath';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
|
|
@ -32,7 +33,7 @@ export const EditorBreadcrumb = (): React.ReactElement | null => {
|
|||
? activeTabId.slice(projectPath.length + 1)
|
||||
: activeTabId;
|
||||
|
||||
return relativePath.split('/');
|
||||
return splitPath(relativePath);
|
||||
}, [activeTabId, projectPath]);
|
||||
|
||||
const handleSegmentClick = useCallback(
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
import React, { useCallback, useRef, useState } from 'react';
|
||||
|
||||
import * as ContextMenu from '@radix-ui/react-context-menu';
|
||||
import { lastSeparatorIndex } from '@shared/utils/platformPath';
|
||||
import {
|
||||
ClipboardCopy,
|
||||
FilePlus,
|
||||
|
|
@ -84,7 +85,7 @@ export const EditorContextMenu = ({
|
|||
const parentDir = target
|
||||
? target.isDir
|
||||
? target.path
|
||||
: target.path.substring(0, target.path.lastIndexOf('/'))
|
||||
: target.path.substring(0, lastSeparatorIndex(target.path))
|
||||
: null;
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import {
|
|||
} from '@renderer/components/ui/dialog';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { sortTreeNodes } from '@renderer/utils/fileTreeBuilder';
|
||||
import { getBasename, lastSeparatorIndex } from '@shared/utils/platformPath';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { ChevronDown, ChevronRight, Folder, FolderOpen, Lock } from 'lucide-react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
|
@ -350,7 +351,7 @@ export const EditorFileTree = ({
|
|||
const item = flatItemsByPath.get(overId);
|
||||
if (item) {
|
||||
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 sourceParent = sourcePath.substring(0, sourcePath.lastIndexOf('/'));
|
||||
const sourceParent = sourcePath.substring(0, lastSeparatorIndex(sourcePath));
|
||||
|
||||
// Validation: same folder = no-op
|
||||
if (sourceParent === destDir) {
|
||||
|
|
@ -543,7 +544,7 @@ export const EditorFileTree = ({
|
|||
<DialogHeader>
|
||||
<DialogTitle className="text-sm">Move to Trash</DialogTitle>
|
||||
<DialogDescription>
|
||||
Move “{deleteConfirmPath?.split('/').pop() ?? ''}” to Trash?
|
||||
Move “{deleteConfirmPath ? getBasename(deleteConfirmPath) : ''}” to Trash?
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import { useEditorKeyboardShortcuts } from '@renderer/hooks/useEditorKeyboardSho
|
|||
import { useStore } from '@renderer/store';
|
||||
import { buildFileAction, buildSelectionAction } from '@renderer/utils/buildSelectionAction';
|
||||
import { shortcutLabel } from '@renderer/utils/platformKeys';
|
||||
import { getBasename, getDirname } from '@shared/utils/platformPath';
|
||||
import {
|
||||
AlertTriangle,
|
||||
HelpCircle,
|
||||
|
|
@ -216,7 +217,7 @@ export const ProjectEditorOverlay = ({
|
|||
const result = await promise;
|
||||
const ipcMs = performance.now() - t0;
|
||||
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);
|
||||
|
||||
|
|
@ -519,7 +520,7 @@ export const ProjectEditorOverlay = ({
|
|||
onToggleMdPreview: isMarkdown ? toggleMdPreview : undefined,
|
||||
});
|
||||
|
||||
const projectName = projectPath.split('/').pop() ?? projectPath;
|
||||
const projectName = getBasename(projectPath) || projectPath;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -785,7 +786,7 @@ export const ProjectEditorOverlay = ({
|
|||
key={`${activeTabId}-${editorResetKey}`}
|
||||
filePath={activeTabId}
|
||||
content={fileContent.content}
|
||||
fileName={activeTabId.split('/').pop() ?? 'file'}
|
||||
fileName={getBasename(activeTabId) || 'file'}
|
||||
mtimeMs={fileContent.mtimeMs}
|
||||
onCursorChange={handleCursorChange}
|
||||
onDraftRecovered={handleDraftRecovered}
|
||||
|
|
@ -803,7 +804,7 @@ export const ProjectEditorOverlay = ({
|
|||
splitRatio={splitRatio}
|
||||
onSplitRatioChange={handleSplitRatioChange}
|
||||
viewKey={activeTabId}
|
||||
baseDir={activeTabId?.substring(0, activeTabId.lastIndexOf('/'))}
|
||||
baseDir={activeTabId ? getDirname(activeTabId) : undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import React, { useCallback, useEffect, useRef, useState } from 'react';
|
|||
import { api } from '@renderer/api';
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { getBasename, lastSeparatorIndex } from '@shared/utils/platformPath';
|
||||
import { Loader2, Search, X } from 'lucide-react';
|
||||
|
||||
import { FileIcon } from './FileIcon';
|
||||
|
|
@ -263,10 +264,9 @@ const SearchFileGroup = ({
|
|||
query,
|
||||
caseSensitive,
|
||||
}: SearchFileGroupProps): React.ReactElement => {
|
||||
const fileName = relativePath.split('/').pop() ?? relativePath;
|
||||
const dirPath = relativePath.includes('/')
|
||||
? relativePath.slice(0, relativePath.lastIndexOf('/'))
|
||||
: '';
|
||||
const fileName = getBasename(relativePath) || relativePath;
|
||||
const sepIdx = lastSeparatorIndex(relativePath);
|
||||
const dirPath = sepIdx >= 0 ? relativePath.slice(0, sepIdx) : '';
|
||||
return (
|
||||
<div className="border-border/50 border-b">
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { useEffect, useState } from 'react';
|
|||
import { api } from '@renderer/api';
|
||||
import { cn } from '@renderer/lib/utils';
|
||||
import { formatRelativeTime } from '@renderer/utils/formatters';
|
||||
import { getBasename } from '@shared/utils/platformPath';
|
||||
import { formatTokensCompact } from '@shared/utils/tokenFormatting';
|
||||
import {
|
||||
AlertCircle,
|
||||
|
|
@ -247,7 +248,7 @@ const FilesTouchedSection = ({
|
|||
</div>
|
||||
<div className="space-y-0.5">
|
||||
{visibleFiles.map((filePath) => {
|
||||
const basename = filePath.split('/').pop() ?? filePath;
|
||||
const basename = getBasename(filePath) || filePath;
|
||||
const fStats = fileStats?.[filePath];
|
||||
return (
|
||||
<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 { computeDisambiguatedTabs } from '@renderer/utils/tabLabelDisambiguation';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { getBasename, lastSeparatorIndex, splitPath } from '@shared/utils/platformPath';
|
||||
|
||||
import type { AppState } from '../types';
|
||||
import type {
|
||||
|
|
@ -330,7 +331,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
|
|||
: null;
|
||||
|
||||
if (relative) {
|
||||
const segments = relative.split('/');
|
||||
const segments = splitPath(relative);
|
||||
// Expand each parent directory sequentially (root → child → grandchild).
|
||||
// Skip the last segment (the file name itself).
|
||||
// Each expandDirectory call is awaited so that its children are merged
|
||||
|
|
@ -577,7 +578,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
|
|||
return;
|
||||
}
|
||||
|
||||
const fileName = filePath.split('/').pop() ?? 'file';
|
||||
const fileName = getBasename(filePath) || 'file';
|
||||
const language = getLanguageFromFileName(fileName);
|
||||
|
||||
const tab: EditorFileTab = {
|
||||
|
|
@ -881,7 +882,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
|
|||
}
|
||||
|
||||
// Refresh parent directory
|
||||
const parentDir = filePath.substring(0, filePath.lastIndexOf('/'));
|
||||
const parentDir = filePath.substring(0, lastSeparatorIndex(filePath));
|
||||
if (parentDir) {
|
||||
await refreshDirectory(get, set, parentDir);
|
||||
}
|
||||
|
|
@ -906,7 +907,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
|
|||
try {
|
||||
const result = await api.editor.moveFile(sourcePath, destDir);
|
||||
const { newPath, isDirectory } = result;
|
||||
const oldParent = sourcePath.substring(0, sourcePath.lastIndexOf('/'));
|
||||
const oldParent = sourcePath.substring(0, lastSeparatorIndex(sourcePath));
|
||||
|
||||
// Record move timestamps for watcher cooldown
|
||||
recentMoveTimestamps.set(sourcePath, Date.now());
|
||||
|
|
@ -917,7 +918,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
|
|||
const tabs = s.editorOpenTabs.map((tab) => {
|
||||
const remapped = remapPath(tab.filePath, sourcePath, newPath);
|
||||
if (remapped === tab.filePath) return tab;
|
||||
const fileName = remapped.split('/').pop() ?? 'file';
|
||||
const fileName = getBasename(remapped) || 'file';
|
||||
return {
|
||||
...tab,
|
||||
id: remapped,
|
||||
|
|
@ -1009,7 +1010,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
|
|||
try {
|
||||
const result = await api.editor.renameFile(sourcePath, newName);
|
||||
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(newPath, Date.now());
|
||||
|
|
@ -1018,7 +1019,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
|
|||
const tabs = s.editorOpenTabs.map((tab) => {
|
||||
const remapped = remapPath(tab.filePath, sourcePath, newPath);
|
||||
if (remapped === tab.filePath) return tab;
|
||||
const fileName = remapped.split('/').pop() ?? 'file';
|
||||
const fileName = getBasename(remapped) || 'file';
|
||||
return {
|
||||
...tab,
|
||||
id: remapped,
|
||||
|
|
@ -1208,7 +1209,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
|
|||
// Refresh parent directory in tree for create/delete
|
||||
if (event.type === 'create' || event.type === 'delete') {
|
||||
invalidateQuickOpenCache();
|
||||
const parentDir = event.path.substring(0, event.path.lastIndexOf('/'));
|
||||
const parentDir = event.path.substring(0, lastSeparatorIndex(event.path));
|
||||
if (parentDir && editorProjectPath) {
|
||||
const existing = dirRefreshDebounceTimers.get(parentDir);
|
||||
if (existing) clearTimeout(existing);
|
||||
|
|
@ -1295,7 +1296,7 @@ async function refreshDirectory(
|
|||
const t0 = performance.now();
|
||||
const result = await api.editor.readDir(dirPath);
|
||||
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;
|
||||
if (!currentTree) return;
|
||||
|
|
|
|||
|
|
@ -116,6 +116,8 @@ export interface TeamSlice {
|
|||
teamByName: Record<string, TeamSummary>;
|
||||
/** O(1) lookup: sessionId -> owning team (lead + history) */
|
||||
teamBySessionId: Record<string, TeamSummary>;
|
||||
/** Centralized git branch cache: normalizedPath → branch name | null */
|
||||
branchByPath: Record<string, string | null>;
|
||||
teamsLoading: boolean;
|
||||
teamsError: string | null;
|
||||
globalTasks: GlobalTask[];
|
||||
|
|
@ -141,6 +143,7 @@ export interface TeamSlice {
|
|||
provisioningError: string | null;
|
||||
kanbanFilterQuery: string | null;
|
||||
provisioningProgressUnsubscribe: (() => void) | null;
|
||||
fetchBranches: (paths: string[]) => Promise<void>;
|
||||
fetchTeams: () => Promise<void>;
|
||||
fetchAllTasks: () => Promise<void>;
|
||||
openTeamsTab: () => void;
|
||||
|
|
@ -213,6 +216,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
teams: [],
|
||||
teamByName: {},
|
||||
teamBySessionId: {},
|
||||
branchByPath: {},
|
||||
teamsLoading: false,
|
||||
teamsError: null,
|
||||
globalTasks: [],
|
||||
|
|
@ -244,6 +248,21 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
deletedTasks: [],
|
||||
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 () => {
|
||||
// Guard: prevent concurrent fetches (component mount + centralized init chain).
|
||||
// Only effective during initial load (when teamsLoading is set to true below).
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@
|
|||
* without pulling in CodeMirror dependencies.
|
||||
*/
|
||||
|
||||
import { getBasename } from '@shared/utils/platformPath';
|
||||
|
||||
import type { EditorSelectionAction, EditorSelectionInfo } from '@shared/types/editor';
|
||||
|
||||
// =============================================================================
|
||||
|
|
@ -66,7 +68,7 @@ export function buildFileAction(
|
|||
filePath: string,
|
||||
projectPath?: string | null
|
||||
): EditorSelectionAction {
|
||||
const fileName = filePath.split('/').pop() ?? 'file';
|
||||
const fileName = getBasename(filePath) || 'file';
|
||||
const displayPath =
|
||||
projectPath && filePath.startsWith(projectPath + '/')
|
||||
? filePath.slice(projectPath.length + 1)
|
||||
|
|
@ -87,7 +89,7 @@ export function buildSelectionAction(
|
|||
type: EditorSelectionAction['type'],
|
||||
info: EditorSelectionInfo
|
||||
): EditorSelectionAction {
|
||||
const fileName = info.filePath.split('/').pop() ?? 'file';
|
||||
const fileName = getBasename(info.filePath) || 'file';
|
||||
const lang = getCodeFenceLanguage(fileName);
|
||||
const lineRef =
|
||||
info.fromLine === info.toLine
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
import { chipToken } from '@renderer/types/inlineChip';
|
||||
import { getCodeFenceLanguage } from '@renderer/utils/buildSelectionAction';
|
||||
import { getBasename } from '@shared/utils/platformPath';
|
||||
|
||||
import type { InlineChip } from '@renderer/types/inlineChip';
|
||||
import type { EditorSelectionAction } from '@shared/types/editor';
|
||||
|
|
@ -31,7 +32,7 @@ export function createChipFromSelection(
|
|||
);
|
||||
if (isDuplicate) return null;
|
||||
|
||||
const fileName = action.filePath.split('/').pop() ?? 'file';
|
||||
const fileName = getBasename(action.filePath) || 'file';
|
||||
return {
|
||||
id: `chip-${++chipCounter}-${Date.now()}`,
|
||||
filePath: action.filePath,
|
||||
|
|
@ -51,7 +52,7 @@ export function createChipFromSelection(
|
|||
);
|
||||
if (isDuplicate) return null;
|
||||
|
||||
const fileName = action.filePath.split('/').pop() ?? 'file';
|
||||
const fileName = getBasename(action.filePath) || 'file';
|
||||
const language = getCodeFenceLanguage(fileName);
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,11 @@
|
|||
* - 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 type { ClaudeMdInjection, ClaudeMdSource, ClaudeMdStats } from '../types/claudeMd';
|
||||
|
|
@ -147,26 +152,9 @@ function normalizeSeparators(input: string, separator: '/' | '\\'): string {
|
|||
return output;
|
||||
}
|
||||
|
||||
/** Local alias — delegates to the shared cross-platform splitPath. */
|
||||
function splitPath(input: string): string[] {
|
||||
const parts: string[] = [];
|
||||
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;
|
||||
return splitPathCrossPlatform(input);
|
||||
}
|
||||
|
||||
function normalizeForComparison(input: string): string {
|
||||
|
|
@ -177,7 +165,7 @@ function normalizeForComparison(input: string): string {
|
|||
* Get the directory containing a file.
|
||||
*/
|
||||
export function getDirectory(filePath: string): string {
|
||||
const lastSep = Math.max(filePath.lastIndexOf('/'), filePath.lastIndexOf('\\'));
|
||||
const lastSep = lastSeparatorIndex(filePath);
|
||||
if (lastSep === -1) return '';
|
||||
return filePath.slice(0, lastSep);
|
||||
}
|
||||
|
|
@ -186,7 +174,7 @@ export function getDirectory(filePath: string): string {
|
|||
* Get the parent directory of a path.
|
||||
*/
|
||||
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
|
||||
return dirPath.slice(0, lastSep);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@
|
|||
* Used by ReviewFileTree (FileChangeSummary) and EditorFileTree (FileTreeEntry).
|
||||
*/
|
||||
|
||||
import { splitPath as splitPathCrossPlatform } from '@shared/utils/platformPath';
|
||||
|
||||
export interface TreeNode<T> {
|
||||
name: string;
|
||||
fullPath: string;
|
||||
|
|
@ -28,7 +30,7 @@ export function buildTree<T>(
|
|||
const root: TreeNode<T> = { name: '', fullPath: '', isFile: false, children: [] };
|
||||
|
||||
for (const item of items) {
|
||||
const parts = getPath(item).split('/');
|
||||
const parts = splitPathCrossPlatform(getPath(item));
|
||||
let current = root;
|
||||
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@
|
|||
* 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.
|
||||
* 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, '/');
|
||||
|
||||
if (p.startsWith('/Users/') || p.startsWith('/home/')) {
|
||||
const parts = p.split('/').filter(Boolean);
|
||||
const parts = splitPath(p);
|
||||
if (parts.length >= 2) {
|
||||
const rest = parts.slice(2).join('/');
|
||||
return rest ? `~/${rest}` : '~';
|
||||
|
|
@ -90,7 +92,7 @@ export function formatProjectPath(path: string): string {
|
|||
}
|
||||
|
||||
if (isWindowsUserPath(path)) {
|
||||
const parts = p.split('/').filter(Boolean);
|
||||
const parts = splitPath(p);
|
||||
if (parts.length >= 3) {
|
||||
const rest = parts.slice(3).join('/');
|
||||
return rest ? `~/${rest}` : '~';
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@
|
|||
* 4. Unique file names get no label (disambiguatedLabel = undefined)
|
||||
*/
|
||||
|
||||
import { splitPath } from '@shared/utils/platformPath';
|
||||
|
||||
import type { EditorFileTab } from '@shared/types/editor';
|
||||
|
||||
/**
|
||||
|
|
@ -49,7 +51,7 @@ export function computeDisambiguatedTabs(tabs: EditorFileTab[]): EditorFileTab[]
|
|||
|
||||
// Split paths into segments for comparison
|
||||
const pathSegments = group.map((tab) => {
|
||||
const parts = tab.filePath.split('/');
|
||||
const parts = splitPath(tab.filePath);
|
||||
// Remove the file name (last segment)
|
||||
parts.pop();
|
||||
return parts;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { normalizePath } from '@renderer/utils/pathNormalize';
|
||||
import { splitPath } from '@shared/utils/platformPath';
|
||||
import { differenceInDays, isToday, isYesterday } from 'date-fns';
|
||||
|
||||
import { DATE_CATEGORY_ORDER } from '../types/tabs';
|
||||
|
|
@ -101,10 +102,7 @@ function trimTrailingPathSep(p: string): string {
|
|||
|
||||
export function projectLabelFromPath(path: string): string {
|
||||
const normalized = trimTrailingPathSep(path);
|
||||
const segments = normalized
|
||||
.split('/')
|
||||
.flatMap((s) => s.split('\\'))
|
||||
.filter(Boolean);
|
||||
const segments = splitPath(normalized);
|
||||
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