diff --git a/src/main/services/ai/commit-message.ts b/src/main/services/ai/commit-message.ts index 21f50bab..2c12b518 100644 --- a/src/main/services/ai/commit-message.ts +++ b/src/main/services/ai/commit-message.ts @@ -1,5 +1,6 @@ import { execSync } from 'node:child_process'; import type { AIProvider, ModelId, ReasoningEffort } from '@shared/types'; +import { isWslGitRepository, spawnGit } from '../git/runtime'; import { parseCLIOutput, spawnCLI } from './providers'; export interface CommitMessageOptions { @@ -35,12 +36,53 @@ function stripCodeFence(text: string): string { .trim(); } -function runGit(cmd: string, cwd: string): string { - try { - return execSync(cmd, { cwd, encoding: 'utf-8', timeout: 5000 }).trim(); - } catch { - return ''; +function runGit(args: string[], cwd: string): Promise { + if (!isWslGitRepository(cwd)) { + try { + return Promise.resolve( + execSync(`git ${args.join(' ')}`, { cwd, encoding: 'utf-8', timeout: 5000 }).trim() + ); + } catch { + return Promise.resolve(''); + } } + + return new Promise((resolve) => { + let stdout = ''; + let settled = false; + + const proc = spawnGit(cwd, args); + + const timeout = setTimeout(() => { + if (settled) return; + settled = true; + if (!proc.killed) { + proc.kill('SIGKILL'); + } + resolve(''); + }, 5000); + + proc.stdout.on('data', (data) => { + stdout += data.toString('utf-8'); + }); + + // Drain stderr to avoid child process blocking on full pipe buffer. + proc.stderr.on('data', () => {}); + + proc.on('error', () => { + clearTimeout(timeout); + if (settled) return; + settled = true; + resolve(''); + }); + + proc.on('close', (code) => { + clearTimeout(timeout); + if (settled) return; + settled = true; + resolve(code === 0 ? stdout.trim() : ''); + }); + }); } export async function generateCommitMessage( @@ -56,9 +98,11 @@ export async function generateCommitMessage( prompt: customPrompt, } = options; - const recentCommits = runGit('git --no-pager log -5 --format="%s"', workdir); - const stagedStat = runGit('git --no-pager diff --cached --stat', workdir); - const stagedDiff = runGit('git --no-pager diff --cached', workdir); + const [recentCommits, stagedStat, stagedDiff] = await Promise.all([ + runGit(['--no-pager', 'log', '-5', '--format=%s'], workdir), + runGit(['--no-pager', 'diff', '--cached', '--stat'], workdir), + runGit(['--no-pager', 'diff', '--cached'], workdir), + ]); const truncatedDiff = stagedDiff.split('\n').slice(0, maxDiffLines).join('\n') || '(no staged changes detected)'; diff --git a/src/main/services/git/GitService.ts b/src/main/services/git/GitService.ts index 3fc4755e..cceeb9ab 100644 --- a/src/main/services/git/GitService.ts +++ b/src/main/services/git/GitService.ts @@ -19,7 +19,14 @@ import type { } from '@shared/types'; import type { SimpleGit, StatusResult } from 'simple-git'; import { decodeBuffer, gitShow } from './encoding'; -import { createGitEnv, createSimpleGit, spawnGit, toGitPath } from './runtime'; +import { + createGitEnv, + createSimpleGit, + isWslGitRepository, + normalizeGitRelativePath, + spawnGit, + toGitPath, +} from './runtime'; const execAsync = promisify(exec); @@ -56,6 +63,13 @@ export class GitService { return createGitEnv(workdir); } + private normalizePathsForGit(paths: string[]): string[] { + if (!isWslGitRepository(this.workdir)) { + return paths; + } + return paths.map((filePath) => normalizeGitRelativePath(toGitPath(this.workdir, filePath))); + } + private async readPorcelainV2Limited(maxEntries: number): Promise { const branchInfo: PorcelainBranchInfo = { current: null, @@ -405,7 +419,8 @@ export class GitService { async commit(message: string, files?: string[]): Promise { if (files && files.length > 0) { - await this.git.add(files); + const normalizedFiles = this.normalizePathsForGit(files); + await this.git.add(normalizedFiles); } const result = await this.git.commit(message); return result.commit; @@ -674,11 +689,13 @@ export class GitService { } async stage(paths: string[]): Promise { - await this.git.add(paths); + const normalizedPaths = this.normalizePathsForGit(paths); + await this.git.add(normalizedPaths); } async unstage(paths: string[]): Promise { - await this.git.raw(['reset', 'HEAD', '--', ...paths]); + const normalizedPaths = this.normalizePathsForGit(paths); + await this.git.raw(['reset', 'HEAD', '--', ...normalizedPaths]); } async discard(filePaths: string | string[]): Promise { diff --git a/src/main/services/git/runtime.ts b/src/main/services/git/runtime.ts index b3c32faa..39ff6ac7 100644 --- a/src/main/services/git/runtime.ts +++ b/src/main/services/git/runtime.ts @@ -3,13 +3,12 @@ import { type SpawnOptionsWithoutStdio, spawn, } from 'node:child_process'; +import { WSL_UNC_PREFIXES } from '@shared/utils/path'; import simpleGit, { type SimpleGit, type SimpleGitOptions } from 'simple-git'; import { getProxyEnvVars } from '../proxy/ProxyConfig'; import { getEnhancedPath } from '../terminal/PtyManager'; import { withSafeDirectoryEnv } from './safeDirectory'; -const WSL_UNC_PREFIXES = ['//wsl.localhost/', '//wsl$/']; - type WslPathInfo = { host: 'wsl.localhost' | 'wsl$'; distro: string; diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 66fe9107..fcd26440 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -4,6 +4,7 @@ import type { WorktreeMergeOptions, WorktreeMergeResult, } from '@shared/types'; +import { getPathBasename } from '@shared/utils/path'; import { AnimatePresence, motion } from 'framer-motion'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { @@ -664,7 +665,7 @@ export default function App() { } // Extract repo name from path (handle both / and \ for Windows compatibility) - const name = selectedPath.split(/[\\/]/).pop() || selectedPath; + const name = getPathBasename(selectedPath); const newRepo: Repository = { name, @@ -699,7 +700,7 @@ export default function App() { } // Extract repo name from path - const name = clonedPath.split(/[\\/]/).pop() || clonedPath; + const name = getPathBasename(clonedPath); const newRepo: Repository = { name, @@ -1095,7 +1096,7 @@ export default function App() { worktrees={sortedWorktrees} activeWorktree={activeWorktree} branches={branches} - projectName={selectedRepo?.split(/[\\/]/).pop() || ''} + projectName={selectedRepo ? getPathBasename(selectedRepo) : ''} isLoading={worktreesLoading} isCreating={createWorktreeMutation.isPending} error={worktreeError} diff --git a/src/renderer/App/hooks/useOpenPathListener.ts b/src/renderer/App/hooks/useOpenPathListener.ts index a710282c..eea4b472 100644 --- a/src/renderer/App/hooks/useOpenPathListener.ts +++ b/src/renderer/App/hooks/useOpenPathListener.ts @@ -1,6 +1,7 @@ +import { getPathBasename } from '@shared/utils/path'; import { useEffect } from 'react'; -import { pathsEqual } from '../storage'; import type { Repository } from '../constants'; +import { pathsEqual } from '../storage'; export function useOpenPathListener( repositories: Repository[], @@ -14,7 +15,7 @@ export function useOpenPathListener( if (existingRepo) { setSelectedRepo(existingRepo.path); } else { - const name = path.split(/[\\/]/).pop() || path; + const name = getPathBasename(path); const newRepo: Repository = { name, path }; const updated = [...repositories, newRepo]; saveRepositories(updated); diff --git a/src/renderer/App/hooks/useRepositoryState.ts b/src/renderer/App/hooks/useRepositoryState.ts index 2e146a10..ce72bc81 100644 --- a/src/renderer/App/hooks/useRepositoryState.ts +++ b/src/renderer/App/hooks/useRepositoryState.ts @@ -1,3 +1,4 @@ +import { getPathBasename } from '@shared/utils/path'; import { useCallback, useEffect, useState } from 'react'; import { normalizeHexColor } from '@/lib/colors'; import { @@ -12,9 +13,9 @@ import { getStoredGroups, migrateRepositoryGroups, pathsEqual, + STORAGE_KEYS, saveActiveGroupId, saveGroups, - STORAGE_KEYS, } from '../storage'; export function useRepositoryState() { @@ -41,7 +42,7 @@ export function useRepositoryState() { parsed = parsed.map((repo) => { if (repo.name.includes('/') || repo.name.includes('\\')) { needsMigration = true; - const fixedName = repo.path.split(/[\\/]/).pop() || repo.path; + const fixedName = getPathBasename(repo.path); return { ...repo, name: fixedName }; } if (repo.groupId && !validGroupIds.has(repo.groupId)) { @@ -155,7 +156,7 @@ export function useRepositoryState() { return; } - const name = path.split(/[\\/]/).pop() || path; + const name = getPathBasename(path); const newRepo: Repository = { name, path, diff --git a/src/renderer/components/chat/SessionBar.tsx b/src/renderer/components/chat/SessionBar.tsx index b35b7b8b..fbdd132f 100644 --- a/src/renderer/components/chat/SessionBar.tsx +++ b/src/renderer/components/chat/SessionBar.tsx @@ -222,7 +222,7 @@ function getSessionDisplayName(session: { const title = session.terminalTitle; if (!title) return session.name; // Skip raw process paths (e.g. "Administrator: C:\...\pwsh.exe") - if (/[/\\](pwsh|powershell|cmd|bash|zsh|sh|fish|nu)(\.exe)?["']?\s*$/i.test(title)) { + if (/[/\\](pwsh|powershell|cmd|bash|zsh|sh|fish|nu|wsl)(\.exe)?["']?\s*$/i.test(title)) { return session.name; } if (/^(Administrator|root)\s*:/i.test(title)) { diff --git a/src/renderer/components/layout/RepositorySidebar.tsx b/src/renderer/components/layout/RepositorySidebar.tsx index 7d8d1576..ec3b0de8 100644 --- a/src/renderer/components/layout/RepositorySidebar.tsx +++ b/src/renderer/components/layout/RepositorySidebar.tsx @@ -1,3 +1,4 @@ +import { isWslUncPath, trimTrailingPathSeparators } from '@shared/utils/path'; import { AnimatePresence, LayoutGroup, motion } from 'framer-motion'; import { ChevronRight, @@ -309,6 +310,8 @@ export function RepositorySidebar({ const renderRepoItem = (repo: Repository, originalIndex: number, sectionGroupId?: string) => { const isSelected = selectedRepo === repo.path; + const displayRepoPath = trimTrailingPathSeparators(repo.path); + const useLtrPathDisplay = isWslUncPath(repo.path); return ( {/* Drop indicator - top */} @@ -376,12 +379,13 @@ export function RepositorySidebar({ {/* Path */}
- {repo.path} + {displayRepoPath}
{/* Drop indicator - bottom */} diff --git a/src/renderer/components/layout/RunningProjectsPopover.tsx b/src/renderer/components/layout/RunningProjectsPopover.tsx index 40d3465d..d50f267f 100644 --- a/src/renderer/components/layout/RunningProjectsPopover.tsx +++ b/src/renderer/components/layout/RunningProjectsPopover.tsx @@ -1,4 +1,5 @@ import type { GitWorktree, TerminalSession } from '@shared/types'; +import { getPathBasename } from '@shared/utils/path'; import { Activity, Bot, @@ -123,8 +124,8 @@ export function RunningProjectsPopover({ return { path, repoPath, - repoName: repoPath.split('/').pop() || repoPath, - branchName: worktree?.branch || path.split('/').pop() || path, + repoName: getPathBasename(repoPath), + branchName: worktree?.branch || getPathBasename(path), worktree, agents, terminals, diff --git a/src/renderer/components/layout/TemporaryWorkspacePanel.tsx b/src/renderer/components/layout/TemporaryWorkspacePanel.tsx index 88af2344..b94ccdc9 100644 --- a/src/renderer/components/layout/TemporaryWorkspacePanel.tsx +++ b/src/renderer/components/layout/TemporaryWorkspacePanel.tsx @@ -1,4 +1,5 @@ import type { TempWorkspaceItem } from '@shared/types'; +import { isWslUncPath } from '@shared/utils/path'; import { motion } from 'framer-motion'; import { FolderGit2, @@ -168,6 +169,7 @@ function TemporaryWorkspaceItemRow({ const activities = useWorktreeActivityStore((s) => s.activities); const activity = activities[item.path] || { agentCount: 0, terminalCount: 0 }; const hasActivity = activity.agentCount > 0 || activity.terminalCount > 0; + const useLtrPathDisplay = isWslUncPath(item.path); const handleContextMenu = (e: React.MouseEvent) => { e.preventDefault(); @@ -201,7 +203,10 @@ function TemporaryWorkspaceItemRow({
{item.path} diff --git a/src/renderer/components/layout/TreeSidebar.tsx b/src/renderer/components/layout/TreeSidebar.tsx index a8316d6d..465398b8 100644 --- a/src/renderer/components/layout/TreeSidebar.tsx +++ b/src/renderer/components/layout/TreeSidebar.tsx @@ -4,6 +4,7 @@ import type { TempWorkspaceItem, WorktreeCreateOptions, } from '@shared/types'; +import { getPathBasename, isWslUncPath, trimTrailingPathSeparators } from '@shared/utils/path'; import { AnimatePresence, LayoutGroup, motion } from 'framer-motion'; import { ChevronRight, @@ -454,7 +455,7 @@ export function TreeSidebar({ e.dataTransfer.setData('text/plain', `worktree:${index}`); const dragImage = document.createElement('div'); - dragImage.textContent = worktree.branch || worktree.path.split(/[\\/]/).pop() || ''; + dragImage.textContent = worktree.branch || getPathBasename(worktree.path); dragImage.style.cssText = ` position: fixed; top: -9999px; @@ -657,6 +658,8 @@ export function TreeSidebar({ const repoWts = worktreesMap[repo.path] || []; const repoMainWorktree = repoWts.find((wt) => wt.isMainWorktree); const workdir = repoMainWorktree?.path || repo.path; + const displayRepoPath = trimTrailingPathSeparators(repo.path); + const useLtrPathDisplay = isWslUncPath(repo.path); return (
@@ -780,10 +783,13 @@ export function TreeSidebar({ {/* Row 3: Path */} - {repo.path} + {displayRepoPath}
{/* Drop indicator - bottom */} @@ -1002,7 +1008,12 @@ export function TreeSidebar({ )}
{tempBasePath && ( - + {tempBasePath} )} @@ -1398,7 +1409,7 @@ export function TreeSidebar({ open={createWorktreeDialogOpen} onOpenChange={setCreateWorktreeDialogOpen} branches={branches} - projectName={selectedRepo?.split('/').pop() || ''} + projectName={selectedRepo ? getPathBasename(selectedRepo) : ''} workdir={workdir} isLoading={isCreating} onSubmit={async (options) => { diff --git a/src/renderer/components/layout/WorktreePanel.tsx b/src/renderer/components/layout/WorktreePanel.tsx index ed42a488..03016f78 100644 --- a/src/renderer/components/layout/WorktreePanel.tsx +++ b/src/renderer/components/layout/WorktreePanel.tsx @@ -1,4 +1,5 @@ import type { GitBranch as GitBranchType, GitWorktree, WorktreeCreateOptions } from '@shared/types'; +import { getPathBasename, isWslUncPath } from '@shared/utils/path'; import { LayoutGroup, motion } from 'framer-motion'; import { Copy, @@ -109,7 +110,7 @@ export function WorktreePanel({ // Create styled drag image const dragImage = document.createElement('div'); - dragImage.textContent = worktree.branch || worktree.path.split(/[\\/]/).pop() || ''; + dragImage.textContent = worktree.branch || getPathBasename(worktree.path); dragImage.style.cssText = ` position: fixed; top: -9999px; @@ -497,6 +498,7 @@ function WorktreeItem({ worktree.isMainWorktree || worktree.branch === 'main' || worktree.branch === 'master'; const branchDisplay = worktree.branch || t('Detached'); const isPrunable = worktree.prunable; + const useLtrPathDisplay = isWslUncPath(worktree.path); const glowEnabled = useGlowEffectEnabled(); // Git sync operations @@ -657,7 +659,8 @@ function WorktreeItem({ {/* Path - use rtl direction to show ellipsis at start, keeping end visible */}
{ diff --git a/src/shared/utils/path.ts b/src/shared/utils/path.ts index dd6df261..18cd8fb9 100644 --- a/src/shared/utils/path.ts +++ b/src/shared/utils/path.ts @@ -3,6 +3,12 @@ * For cross-platform path normalization */ +/** + * WSL UNC path prefixes used across renderer/main. + * Keep this as the single source of truth for WSL UNC detection rules. + */ +export const WSL_UNC_PREFIXES = ['//wsl.localhost/', '//wsl$/'] as const; + /** * Normalize path separators to forward slashes * @param p Original path @@ -21,3 +27,41 @@ export function normalizePath(p: string): string { export function joinPath(...segments: string[]): string { return segments.filter(Boolean).join('/').replace(/\\/g, '/').replace(/\/+/g, '/'); } + +/** + * Remove trailing path separators from a path string. + * Preserves root paths like "/" and "C:\". + * @param inputPath Original path + * @returns Path without trailing separators + */ +export function trimTrailingPathSeparators(inputPath: string): string { + if (!inputPath) return inputPath; + if (/^[a-zA-Z]:[\\/]?$/.test(inputPath)) return inputPath; + + const trimmed = inputPath.replace(/[\\/]+$/, ''); + return trimmed || inputPath; +} + +/** + * Whether path is a Windows WSL UNC path. + * Supports both "\\wsl.localhost\..." and "//wsl.localhost/..." forms. + * @param inputPath Original path + * @returns True when path points to WSL via UNC prefix + */ +export function isWslUncPath(inputPath: string): boolean { + const normalized = inputPath.replace(/\\/g, '/'); + return WSL_UNC_PREFIXES.some((prefix) => normalized.toLowerCase().startsWith(prefix)); +} + +/** + * Get the final path segment from a filesystem path. + * Handles both "/" and "\" separators and ignores trailing separators. + * @param inputPath Original path + * @returns Last segment or the original input when parsing fails + */ +export function getPathBasename(inputPath: string): string { + const trimmed = trimTrailingPathSeparators(inputPath); + if (!trimmed) return inputPath; + const segments = trimmed.split(/[\\/]/); + return segments[segments.length - 1] || inputPath; +}