diff --git a/src/main/ipc/floating-workspace-directory.test.ts b/src/main/ipc/floating-workspace-directory.test.ts index f10f16efdd6e..be7614cb857d 100644 --- a/src/main/ipc/floating-workspace-directory.test.ts +++ b/src/main/ipc/floating-workspace-directory.test.ts @@ -165,6 +165,32 @@ describe('floating workspace directory', () => { ).resolves.toBe(path.join(userDataDir, 'floating-workspace')) }) + // A trailing space is a legal POSIX directory name (Google Drive exports them), so a + // picker-verified path must survive trust, settings sanitization and cwd resolution. + // On Windows this passes before and after the fix: NTFS strips trailing spaces at + // creation, and Win32 path APIs normalize the spellings back together. + it('keeps a directory whose name ends in a space through trust and resolution', async () => { + const store = createStore() + const selectedDir = path.join(tempRoot, 'gdrive notes ') + await mkdir(selectedDir) + const canonicalSelectedDir = await realpath(selectedDir) + + await trustFloatingWorkspaceDirectory(store, selectedDir) + + expect(store.settings.floatingTerminalTrustedCwds).toEqual([canonicalSelectedDir]) + + await expect( + resolveFloatingTerminalCwd(store as never, { + path: selectedDir, + requireTrusted: true + }) + ).resolves.toBe(canonicalSelectedDir) + await expect( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the sanitizer only reads the settings members createStore() stubs. + sanitizeFloatingWorkspaceDirectorySetting(store as never, selectedDir) + ).resolves.toBe(canonicalSelectedDir) + }) + it('still resolves accessible ad hoc terminal directories when trust is not required', async () => { const store = createStore() const arbitraryDir = path.join(tempRoot, 'terminal-only') diff --git a/src/main/ipc/floating-workspace-directory.ts b/src/main/ipc/floating-workspace-directory.ts index 3b1c1bdc1c87..115727dff073 100644 --- a/src/main/ipc/floating-workspace-directory.ts +++ b/src/main/ipc/floating-workspace-directory.ts @@ -29,10 +29,12 @@ async function canonicalizeAccessibleDirectory(dirPath: string): Promise { return new Set( (settings.floatingTerminalTrustedCwds ?? []) - .map((trustedPath) => trustedPath.trim()) .filter((trustedPath) => trustedPath.length > 0) .map(resolveFloatingWorkspaceInput) ) @@ -72,17 +74,23 @@ export async function resolveFloatingTerminalCwd( store: { getSettings(): FloatingTerminalSettings }, args?: FloatingTerminalCwdRequest ): Promise { - const configuredPath = typeof args?.path === 'string' ? args.path.trim() : '' - if (!configuredPath) { + const configuredPath = typeof args?.path === 'string' ? args.path : '' + if (!configuredPath.trim()) { return args?.requireTrusted === true ? ensureDefaultFloatingWorkspacePath() : resolveFloatingWorkspaceInput('~') } - const cwd = resolveFloatingWorkspaceInput(configuredPath) - const canonicalCwd = await canonicalizeAccessibleDirectory(cwd) + // Why raw first: a trailing space is a legal POSIX directory name, so a stored + // picker-verified path must resolve as typed; the trim only cleans typed typos. + let cwd = resolveFloatingWorkspaceInput(configuredPath) + let canonicalCwd = await canonicalizeAccessibleDirectory(cwd) if (!canonicalCwd) { - return ensureDefaultFloatingWorkspacePath() + cwd = resolveFloatingWorkspaceInput(configuredPath.trim()) + canonicalCwd = await canonicalizeAccessibleDirectory(cwd) + if (!canonicalCwd) { + return ensureDefaultFloatingWorkspacePath() + } } // Only picker-approved directories may become terminal cwd. @@ -122,8 +130,11 @@ export async function sanitizeFloatingWorkspaceDirectorySetting( if (trimmed === '~') { return '~' } - const resolvedDir = resolveFloatingWorkspaceInput(trimmed) - const canonicalDir = await canonicalizeAccessibleDirectory(resolvedDir) + // Why raw first: a trailing space is a legal POSIX directory name, so a picked + // directory must sanitize as typed; the trim only cleans typed typos. + const canonicalDir = + (await canonicalizeAccessibleDirectory(resolveFloatingWorkspaceInput(dirPath))) ?? + (await canonicalizeAccessibleDirectory(resolveFloatingWorkspaceInput(trimmed))) if (!canonicalDir || !isTrustedFloatingWorkspaceDirectory(canonicalDir, store.getSettings())) { return '' } diff --git a/src/main/ipc/repos-create.test.ts b/src/main/ipc/repos-create.test.ts index bf95a2ee5f70..e1ec06df2596 100644 --- a/src/main/ipc/repos-create.test.ts +++ b/src/main/ipc/repos-create.test.ts @@ -8,6 +8,7 @@ const { mockStore, mkdirMock, accessMock, + statMock, readdirMock, rmMock, gitExecFileAsyncMock, @@ -26,6 +27,7 @@ const { }, mkdirMock: vi.fn(), accessMock: vi.fn(), + statMock: vi.fn(), readdirMock: vi.fn(), rmMock: vi.fn(), gitExecFileAsyncMock: vi.fn(), @@ -45,6 +47,7 @@ vi.mock('electron', () => ({ vi.mock('fs/promises', () => ({ mkdir: mkdirMock, access: accessMock, + stat: statMock, readdir: readdirMock, rm: rmMock })) @@ -122,6 +125,7 @@ describe('repos:create', () => { prepareLocalWorktreeRootForRepoMock.mockReset().mockResolvedValue(undefined) accessMock.mockReset().mockRejectedValue(new Error('ENOENT')) + statMock.mockReset().mockRejectedValue(new Error('ENOENT')) readdirMock.mockReset().mockResolvedValue([]) mkdirMock.mockReset().mockResolvedValue(undefined) rmMock.mockReset().mockResolvedValue(undefined) @@ -253,6 +257,88 @@ describe('repos:create', () => { expect(mkdirMock).toHaveBeenNthCalledWith(2, tmpPath('brand-new'), { recursive: false }) }) + // A trailing space is a legal POSIX directory name (Google Drive exports them), so a + // picked parent that exists exactly as typed must win over the typed-typo trim. + it('creates inside a picked parent whose name ends in a space', async () => { + const spacedParent = '/tmp/gdrive repo ' + statMock.mockImplementation(async (probed: string) => { + if (probed === spacedParent) { + return { isDirectory: () => true } + } + throw new Error('ENOENT') + }) + + const result = await callCreate({ parentPath: spacedParent, name: 'proj', kind: 'folder' }) + + expect(mkdirMock).toHaveBeenNthCalledWith(1, spacedParent, { recursive: true }) + expect(mkdirMock).toHaveBeenNthCalledWith(2, join(spacedParent, 'proj'), { recursive: false }) + expect(mockStore.addRepo).toHaveBeenCalledWith( + expect.objectContaining({ path: join(spacedParent, 'proj') }) + ) + expect(result).toHaveProperty('repo.path', join(spacedParent, 'proj')) + }) + + // The flip side: a typed typo whose trimmed spelling exists keeps the old cleanup. + it('falls back to the trimmed spelling when the raw parent does not exist', async () => { + accessMock.mockImplementation(async (probed: string) => { + if (probed === '/tmp/typo') { + return undefined + } + throw new Error('ENOENT') + }) + + const result = await callCreate({ parentPath: '/tmp/typo ', name: 'proj', kind: 'folder' }) + + expect(mkdirMock).toHaveBeenNthCalledWith(1, '/tmp/typo', { recursive: true }) + expect(result).toHaveProperty('repo.path', join('/tmp/typo', 'proj')) + }) + + // A raw spelling that names a file must not win raw-first: only an existing + // directory may, so the trim can still reach the directory the user meant. + it('falls back to the trimmed directory when the raw parent names a file', async () => { + const rawParent = '/tmp/file parent ' + const trimmedParent = '/tmp/file parent' + statMock.mockImplementation(async (probed: string) => { + if (probed === rawParent) { + return { isDirectory: () => false } + } + throw new Error('ENOENT') + }) + accessMock.mockImplementation(async (probed: string) => { + if (probed === rawParent) { + // access() succeeds on any existing file — how the raw probe used to + // mistake a file for the picked parent. + return undefined + } + throw new Error('ENOENT') + }) + + const result = await callCreate({ parentPath: rawParent, name: 'proj', kind: 'folder' }) + + expect(mkdirMock).toHaveBeenNthCalledWith(1, trimmedParent, { recursive: true }) + expect(mkdirMock).toHaveBeenNthCalledWith(2, join(trimmedParent, 'proj'), { recursive: false }) + expect(result).toHaveProperty('repo.path', join(trimmedParent, 'proj')) + }) + + // A probe failure that is not "absent" (EACCES, ELOOP...) must surface as an error, + // not silently fall back to the trimmed spelling and create in an unchosen directory. + it('returns an error when probing the raw parent fails with EACCES', async () => { + const lockedParent = '/tmp/locked repo ' + statMock.mockRejectedValue( + Object.assign(new Error('EACCES: permission denied, access'), { code: 'EACCES' }) + ) + + const result = await callCreate({ parentPath: lockedParent, name: 'proj', kind: 'folder' }) + + expect(result).toEqual({ + error: 'Cannot access parent directory: EACCES: permission denied, access', + errorCode: 'parentInaccessible', + errorDetail: 'EACCES: permission denied, access' + }) + expect(mkdirMock).not.toHaveBeenCalled() + expect(mockStore.addRepo).not.toHaveBeenCalled() + }) + it('creates a missing default parent before creating the project directory', async () => { const result = await callCreate({ parentPath: defaultProjectParent, diff --git a/src/main/ipc/repos/repo-creation-handlers.ts b/src/main/ipc/repos/repo-creation-handlers.ts index c543995a761b..550e254ab8ce 100644 --- a/src/main/ipc/repos/repo-creation-handlers.ts +++ b/src/main/ipc/repos/repo-creation-handlers.ts @@ -1,7 +1,7 @@ import type { BrowserWindow } from 'electron' import { ipcMain } from 'electron' import { randomUUID } from 'node:crypto' -import { access, mkdir, readdir, rm } from 'node:fs/promises' +import { access, mkdir, readdir, rm, stat } from 'node:fs/promises' import { homedir } from 'node:os' import { isAbsolute, join } from 'node:path' import type { Store } from '../../persistence' @@ -49,6 +49,42 @@ function getDefaultCreateProjectParent(store: Store): string { return join(home, 'orca', 'projects') } +// Only ENOENT/ENOTDIR prove the probed path is absent (ENOTDIR: a parent component is a +// file); EACCES/ELOOP etc. must not trigger the trim fallback. Tests reject plain Errors +// without a code, so an ENOENT/ENOTDIR-looking message counts too. +function isAbsentPathError(err: unknown): boolean { + const code = + err && typeof err === 'object' && 'code' in err + ? (err as NodeJS.ErrnoException).code + : undefined + return ( + code === 'ENOENT' || + code === 'ENOTDIR' || + (code === undefined && err instanceof Error && /ENOENT|ENOTDIR/.test(err.message)) + ) +} + +// Why: a trailing space is a legal POSIX directory name (Google Drive exports them), so a +// picker-returned parent that exists as a directory exactly as typed wins; the trim stays +// only for typed typos and raw spellings naming a non-directory (a file). +async function resolveCreateParentPath(requestedPath: string): Promise { + const trimmed = requestedPath.trim() + if (!trimmed || trimmed === requestedPath) { + return trimmed + } + try { + if ((await stat(requestedPath)).isDirectory()) { + return requestedPath + } + return trimmed + } catch (err) { + if (!isAbsentPathError(err)) { + throw err + } + return trimmed + } +} + export function registerRepoCreationHandlers(mainWindow: BrowserWindow, store: Store): void { ipcMain.handle('repos:isGitAvailable', () => probeLocalGitAvailability()) ipcMain.handle('repos:getDefaultCreateProjectParent', () => getDefaultCreateProjectParent(store)) @@ -81,8 +117,16 @@ export function registerRepoCreationHandlers(mainWindow: BrowserWindow, store: S args: { parentPath: string; name: string; kind: 'git' | 'folder' } ): Promise<{ repo: Repo } | RepoCreateFailure> => { const name = args.name?.trim() ?? '' - const parentPath = args.parentPath?.trim() ?? '' - // Untrusted IPC kinds must not skip Git initialization yet persist as Git projects. + let parentPath: string + try { + parentPath = await resolveCreateParentPath(args.parentPath ?? '') + } catch (err) { + // Why: an unexpected probe failure (EACCES...) must not fall back to the trimmed + // spelling and create the project in a directory the user did not pick. + const message = err instanceof Error ? err.message : String(err) + return repoCreateFailure('parentInaccessible', { detail: message }) + } + // Why: IPC input is untrusted — coerce to the narrow union so a bogus kind can't skip git init yet persist in the store. const repoKind: 'git' | 'folder' = args.kind === 'folder' ? 'folder' : 'git' if (!name) { diff --git a/src/main/runtime/runtime-repository-registration-controller.ts b/src/main/runtime/runtime-repository-registration-controller.ts index 4da48b8d8a7c..4f9948d324e1 100644 --- a/src/main/runtime/runtime-repository-registration-controller.ts +++ b/src/main/runtime/runtime-repository-registration-controller.ts @@ -33,6 +33,28 @@ type RuntimeRepositoryRegistrationDependencies = { notifyReposChanged: () => void } +// Why: a trailing space is a legal directory name (Google Drive exports them), so a +// picker-returned parent that exists as a directory exactly as requested wins; the +// trim stays only for typed typos and raw spellings naming a non-directory (a file). +// Other probe failures must surface instead of silently relocating the project. +async function resolveCreateParentPathRawFirst(requestedPath: string): Promise { + const trimmed = requestedPath.trim() + if (!trimmed || trimmed === requestedPath) { + return trimmed + } + try { + if ((await stat(requestedPath)).isDirectory()) { + return requestedPath + } + return trimmed + } catch (error) { + if (!isENOENT(error)) { + throw error + } + return trimmed + } +} + export class RuntimeRepositoryRegistrationController { constructor(private readonly deps: RuntimeRepositoryRegistrationDependencies) {} @@ -124,7 +146,15 @@ export class RuntimeRepositoryRegistrationController { > { const store = this.requireStore() const trimmedName = name.trim() - const trimmedParentPath = parentPath.trim() + let parentDir: string + try { + parentDir = await resolveCreateParentPathRawFirst(parentPath) + } catch (error) { + // Why: an unexpected probe failure (EACCES...) must not fall back to the trimmed + // spelling and create the project in a directory the user did not pick. + const message = error instanceof Error ? error.message : String(error) + return repoCreateFailure('parentInaccessible', { detail: message }) + } const repoKind: 'git' | 'folder' = kind === 'folder' ? 'folder' : 'git' if (!trimmedName) { return repoCreateFailure('nameEmpty') @@ -132,21 +162,21 @@ export class RuntimeRepositoryRegistrationController { if (/[\\/]/.test(trimmedName) || trimmedName === '.' || trimmedName === '..') { return repoCreateFailure('nameInvalid') } - if (!trimmedParentPath) { + if (!parentDir) { return repoCreateFailure('parentRequired') } - if (!isAbsolute(trimmedParentPath)) { + if (!isAbsolute(parentDir)) { return repoCreateFailure('parentNotAbsolute') } try { - await refuseWslHostWindowsDrivePath(trimmedParentPath) + await refuseWslHostWindowsDrivePath(parentDir) } catch (error) { // An optional field: older clients read only `error`. return error instanceof WslHostWindowsDriveRefusalError ? { error: error.message, windowsDriveRefusal: error.data } : { error: error instanceof Error ? error.message : String(error) } } - const targetPath = join(trimmedParentPath, trimmedName) + const targetPath = join(parentDir, trimmedName) // Qualify by host: identical SSH paths name another machine's directory. const findLocalAtTarget = (): Repo | undefined => store @@ -162,7 +192,7 @@ export class RuntimeRepositoryRegistrationController { let createdDir = false try { - await mkdir(trimmedParentPath, { recursive: true }) + await mkdir(parentDir, { recursive: true }) const existingStat = await stat(targetPath).catch((error: unknown) => { if (isENOENT(error)) { return null diff --git a/src/main/runtime/runtime-repository-registration-create.test.ts b/src/main/runtime/runtime-repository-registration-create.test.ts new file mode 100644 index 000000000000..d2718d59246b --- /dev/null +++ b/src/main/runtime/runtime-repository-registration-create.test.ts @@ -0,0 +1,96 @@ +/** + * Pins the raw-first parent resolution of RuntimeRepositoryRegistrationController.create: + * a picker-returned parent with a trailing space (legal POSIX name) must be used as + * typed when it exists, with the trim kept only for typed typos (#25386). + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +const { prepareLocalWorktreeRootForRepoMock, detectRepoIconAndUpstreamMock } = vi.hoisted(() => ({ + prepareLocalWorktreeRootForRepoMock: vi.fn(async () => undefined), + detectRepoIconAndUpstreamMock: vi.fn(async () => ({})) +})) + +vi.mock('../repo-icon-autodetect', () => ({ + detectRepoIconAndUpstream: detectRepoIconAndUpstreamMock +})) + +vi.mock('../worktree-root-preparation', () => ({ + prepareLocalWorktreeRootForRepo: prepareLocalWorktreeRootForRepoMock +})) + +import { RuntimeRepositoryRegistrationController } from './runtime-repository-registration-controller' + +function makeController(): RuntimeRepositoryRegistrationController { + return new RuntimeRepositoryRegistrationController({ + getStore: () => + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: create() only reads the repo members stubbed here. + ({ + getRepos: () => [], + addRepo: () => undefined, + getRepo: () => undefined, + updateRepo: () => undefined + }) as never, + invalidateResolvedWorktrees: () => undefined, + invalidateWorktreeScan: () => undefined, + notifyReposChanged: () => undefined + }) +} + +describe('RuntimeRepositoryRegistrationController.create parent resolution', () => { + let base: string + + beforeEach(() => { + base = mkdtempSync(join(tmpdir(), 'orca-repo-create-')) + }) + + afterEach(() => { + rmSync(base, { recursive: true, force: true }) + }) + + it('creates inside a trailing-space parent that exists exactly as requested', async () => { + const parent = join(base, 'cloud dir ') + mkdirSync(parent) + const result = await makeController().create(parent, 'kid', 'folder') + expect('error' in result && result.error).toBeFalsy() + if (!('error' in result)) { + expect(result.repo.path).toBe(join(parent, 'kid')) + } + }) + + it('falls back to the trimmed spelling when the raw parent does not exist', async () => { + const requested = join(base, 'typo dir ') + const result = await makeController().create(requested, 'kid', 'folder') + expect('error' in result && result.error).toBeFalsy() + if (!('error' in result)) { + expect(result.repo.path).toBe(join(base, 'typo dir', 'kid')) + } + }) + + it('falls back to the trimmed directory when the raw spelling names a file', async () => { + const trimmedDir = join(base, 'real dir') + mkdirSync(trimmedDir) + // The raw spelling exists but is a file; only a directory parent may win raw-first. + writeFileSync(`${trimmedDir} `, 'a file, not a directory') + const result = await makeController().create(`${trimmedDir} `, 'kid', 'folder') + expect('error' in result && result.error).toBeFalsy() + if (!('error' in result)) { + expect(result.repo.path).toBe(join(trimmedDir, 'kid')) + } + }) + + it('surfaces a non-absent probe failure instead of creating at the trimmed spelling', async () => { + const blocker = join(base, 'blocker') + writeFileSync(blocker, 'a file, not a directory') + const requested = join(blocker, 'cloud dir ') + const result = await makeController().create(requested, 'kid', 'folder') + expect('error' in result).toBe(true) + if ('error' in result) { + expect(result.error).toMatch(/Cannot access parent directory/) + expect(result.errorCode).toBe('parentInaccessible') + } + }) +}) diff --git a/src/main/runtime/worktree-selector-trailing-space-path.test.ts b/src/main/runtime/worktree-selector-trailing-space-path.test.ts new file mode 100644 index 000000000000..9974bb06983a --- /dev/null +++ b/src/main/runtime/worktree-selector-trailing-space-path.test.ts @@ -0,0 +1,50 @@ +// A trailing space is a legal POSIX directory name (Google Drive exports them), so the +// worktree selector chain — scan parsing, id minting/parsing, selector matching — must +// preserve it end to end. Characterization: these pass today; a red here means a +// serialization layer started eating the space (#25386 cross-machine selector_not_found). +import { describe, expect, it } from 'vitest' +import { parseWorktreeList } from '../../shared/git-worktree-porcelain-parser' +import { splitWorktreeId, worktreeIdentityKey, worktreeIdsEqual } from '../../shared/worktree/id' +import { runtimePathsEqual } from './runtime-worktree-path-identity' + +const REPO_ID = '01234567-89ab-cdef-0123-456789abcdef' +const SPACED_REPO_PATH = '/Users/me/gdrive repo ' +const SPACED_WORKTREE_PATH = '/Users/me/orca/workspaces/gdrive repo /feature ' + +describe('trailing-space paths survive the selector chain', () => { + it('parses a porcelain scan without trimming the path', () => { + const output = + `worktree ${SPACED_REPO_PATH}\nHEAD abc\nbranch refs/heads/main\n\n` + + `worktree ${SPACED_WORKTREE_PATH}\nHEAD def\nbranch refs/heads/feature\n` + + const worktrees = parseWorktreeList(output) + + expect(worktrees.map((worktree) => worktree.path)).toEqual([ + SPACED_REPO_PATH, + SPACED_WORKTREE_PATH + ]) + }) + + it('mints and re-splits worktree ids with the space intact', () => { + const worktreeId = `${REPO_ID}::${SPACED_WORKTREE_PATH}` + + expect(splitWorktreeId(worktreeId)).toEqual({ + repoId: REPO_ID, + worktreePath: SPACED_WORKTREE_PATH + }) + expect(worktreeIdsEqual(worktreeId, `${REPO_ID}::${SPACED_WORKTREE_PATH}`)).toBe(true) + expect(worktreeIdentityKey(worktreeId)).toBe( + `${REPO_ID}::${SPACED_WORKTREE_PATH.normalize('NFC')}` + ) + }) + + it('matches path selectors byte-exact on the trailing space', () => { + // Interior duplicate slashes fold; the trailing space must survive the fold. + expect( + runtimePathsEqual(SPACED_WORKTREE_PATH, SPACED_WORKTREE_PATH.replace('/Users/', '/Users//')) + ).toBe(true) + expect(runtimePathsEqual(SPACED_WORKTREE_PATH, SPACED_WORKTREE_PATH.replace(/ $/, ''))).toBe( + false + ) + }) +}) diff --git a/src/renderer/src/components/sidebar/repo-create-error-copy.ts b/src/renderer/src/components/sidebar/repo-create-error-copy.ts index 0b32b726098e..8c0d32870a2e 100644 --- a/src/renderer/src/components/sidebar/repo-create-error-copy.ts +++ b/src/renderer/src/components/sidebar/repo-create-error-copy.ts @@ -14,6 +14,12 @@ const COPY: Record string | null> parentRequired: () => translate('sidebar.createProject.errors.parentRequired', EN.parentRequired), parentNotAbsolute: () => translate('sidebar.createProject.errors.parentNotAbsolute', EN.parentNotAbsolute), + parentInaccessible: ({ detail }) => + detail === null + ? null + : translate('sidebar.createProject.errors.parentInaccessible', EN.parentInaccessible, { + detail + }), targetInaccessible: ({ detail }) => detail === null ? null diff --git a/src/renderer/src/components/sidebar/useCreateRepo.ts b/src/renderer/src/components/sidebar/useCreateRepo.ts index 5e310884f398..bb86b6a771e6 100644 --- a/src/renderer/src/components/sidebar/useCreateRepo.ts +++ b/src/renderer/src/components/sidebar/useCreateRepo.ts @@ -80,11 +80,14 @@ export function useCreateRepo( const handleCreate = useCallback(async () => { const name = createName.trim() - const parentPath = createParent.trim() - // Block unresolved hosts so creation cannot silently fall back to this computer. - if (!name || !parentPath || options.hostId === null) { + // Why: null means the chosen host is unresolved (e.g. its server is coming up); without a + // host id the request below would run on this computer. + if (!name || !createParent.trim() || options.hostId === null) { return } + // Why untrimmed: the picker returns a verified path and a trailing space can be the + // real directory name; main resolves raw-first and only trims typed typos. + const parentPath = createParent const requestHostToken = hostTokenRef.current const gen = ++createGenRef.current const isCurrent = (): boolean => diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index 6845fd48e782..27c6470722ef 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2235,6 +2235,7 @@ "initialCommitFailed": "Failed to create initial commit: {{detail}}", "nameEmpty": "Name cannot be empty", "nameInvalid": "Name cannot contain slashes or be \".\" / \"..\"", + "parentInaccessible": "Cannot access parent directory: {{detail}}", "parentNotAbsolute": "Parent directory must be an absolute path", "parentRequired": "Parent directory is required", "prepareDirectoryFailed": "Failed to prepare directory: {{detail}}", diff --git a/src/renderer/src/i18n/locales/de.json b/src/renderer/src/i18n/locales/de.json index c2ddf2a471d2..4be420d90996 100644 --- a/src/renderer/src/i18n/locales/de.json +++ b/src/renderer/src/i18n/locales/de.json @@ -109,6 +109,7 @@ "nameInvalid": "Name darf weder Schrägstriche enthalten noch \".\" / \"..\" sein", "parentRequired": "Übergeordnetes Verzeichnis ist erforderlich", "parentNotAbsolute": "Übergeordnetes Verzeichnis muss ein absoluter Pfad sein", + "parentInaccessible": "Zugriff auf übergeordnetes Verzeichnis nicht möglich: {{detail}}", "targetInaccessible": "Zugriff auf Zielpfad nicht möglich: {{detail}}", "targetNotEmpty": "„{{name}}“ existiert bereits an diesem Ort und ist nicht leer.", "targetNotFolder": "„{{name}}“ existiert bereits an diesem Ort und ist kein Ordner.", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index b250a0bb90e4..55b5a0384279 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -109,6 +109,7 @@ "nameInvalid": "Name cannot contain slashes or be \".\" / \"..\"", "parentRequired": "Parent directory is required", "parentNotAbsolute": "Parent directory must be an absolute path", + "parentInaccessible": "Cannot access parent directory: {{detail}}", "targetInaccessible": "Cannot access target path: {{detail}}", "targetNotEmpty": "\"{{name}}\" already exists at this location and is not empty.", "targetNotFolder": "\"{{name}}\" already exists at this location and is not a folder.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 78e092680bca..8914f8dc81d5 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -20927,6 +20927,7 @@ "nameInvalid": "El nombre no puede contener barras ni ser \".\" / \"..\"", "parentRequired": "Se requiere un directorio padre", "parentNotAbsolute": "El directorio padre debe ser una ruta absoluta", + "parentInaccessible": "No se puede acceder al directorio padre: {{detail}}", "targetInaccessible": "No se puede acceder a la ruta de destino: {{detail}}", "targetNotEmpty": "\"{{name}}\" ya existe en esta ubicación y no está vacío.", "targetNotFolder": "\"{{name}}\" ya existe en esta ubicación y no es una carpeta.", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 43b585c934b9..5c04bae8bf5c 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -20685,6 +20685,7 @@ "nameInvalid": "Le nom ne peut pas contenir de barres obliques ni être \".\" / \"..\"", "parentRequired": "Le répertoire parent est requis", "parentNotAbsolute": "Le répertoire parent doit être un chemin absolu", + "parentInaccessible": "Impossible d’accéder au répertoire parent : {{detail}}", "targetInaccessible": "Impossible d’accéder au chemin cible : {{detail}}", "targetNotEmpty": "« {{name}} » existe déjà à cet emplacement et n’est pas vide.", "targetNotFolder": "« {{name}} » existe déjà à cet emplacement et n’est pas un dossier.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index a113a54b4237..55b6977bec55 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -20009,6 +20009,7 @@ "nameInvalid": "名前にスラッシュは使用できず、\".\" / \"..\" にもできません", "parentRequired": "親ディレクトリは必須です", "parentNotAbsolute": "親ディレクトリは絶対パスである必要があります", + "parentInaccessible": "親ディレクトリにアクセスできません: {{detail}}", "targetInaccessible": "対象パスにアクセスできません: {{detail}}", "targetNotEmpty": "この場所にはすでに「{{name}}」が存在し、空ではありません。", "targetNotFolder": "この場所にはすでに「{{name}}」が存在し、フォルダーではありません。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 8075b7fff3e2..f93d1acfe513 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -20009,6 +20009,7 @@ "nameInvalid": "이름에 슬래시를 포함하거나 \".\" / \"..\"로 지정할 수 없습니다", "parentRequired": "상위 디렉터리가 필요합니다", "parentNotAbsolute": "상위 디렉터리는 절대 경로여야 합니다", + "parentInaccessible": "상위 디렉터리에 접근할 수 없습니다: {{detail}}", "targetInaccessible": "대상 경로에 접근할 수 없습니다: {{detail}}", "targetNotEmpty": "이 위치에 \"{{name}}\"이(가) 이미 있으며 비어 있지 않습니다.", "targetNotFolder": "이 위치에 \"{{name}}\"이(가) 이미 있으며 폴더가 아닙니다.", diff --git a/src/renderer/src/i18n/locales/pt.json b/src/renderer/src/i18n/locales/pt.json index 2e8eac2a3eac..08a8347acb22 100644 --- a/src/renderer/src/i18n/locales/pt.json +++ b/src/renderer/src/i18n/locales/pt.json @@ -462,6 +462,7 @@ "nameInvalid": "O nome não pode conter barras nem ser \".\" / \"..\"", "parentRequired": "O diretório pai é obrigatório", "parentNotAbsolute": "O diretório pai deve ser um caminho absoluto", + "parentInaccessible": "Não é possível acessar o diretório pai: {{detail}}", "targetInaccessible": "Não é possível acessar o caminho de destino: {{detail}}", "targetNotEmpty": "\"{{name}}\" já existe neste local e não está vazio.", "targetNotFolder": "\"{{name}}\" já existe neste local e não é uma pasta.", diff --git a/src/renderer/src/i18n/locales/ru.json b/src/renderer/src/i18n/locales/ru.json index bee849a6bd48..ab64f060f1d9 100644 --- a/src/renderer/src/i18n/locales/ru.json +++ b/src/renderer/src/i18n/locales/ru.json @@ -109,6 +109,7 @@ "nameInvalid": "Имя не может содержать косые черты или быть \".\" / \"..\"", "parentRequired": "Необходимо указать родительскую папку", "parentNotAbsolute": "Путь к родительской папке должен быть абсолютным", + "parentInaccessible": "Не удалось получить доступ к родительской папке: {{detail}}", "targetInaccessible": "Не удалось получить доступ к целевому пути: {{detail}}", "targetNotEmpty": "«{{name}}» уже существует в этом расположении и содержит данные.", "targetNotFolder": "«{{name}}» уже существует в этом расположении и не является папкой.", diff --git a/src/renderer/src/i18n/locales/tr.json b/src/renderer/src/i18n/locales/tr.json index f7063e33d347..23992b946073 100644 --- a/src/renderer/src/i18n/locales/tr.json +++ b/src/renderer/src/i18n/locales/tr.json @@ -109,6 +109,7 @@ "nameInvalid": "Ad eğik çizgi içeremez veya \".\" / \"..\" olamaz", "parentRequired": "Üst dizin gereklidir", "parentNotAbsolute": "Üst dizin mutlak bir yol olmalıdır", + "parentInaccessible": "Üst dizine erişilemiyor: {{detail}}", "targetInaccessible": "Hedef yola erişilemiyor: {{detail}}", "targetNotEmpty": "\"{{name}}\" bu konumda zaten var ve boş değil.", "targetNotFolder": "\"{{name}}\" bu konumda zaten var ve bir klasör değil.", diff --git a/src/renderer/src/i18n/locales/zh-Hant.json b/src/renderer/src/i18n/locales/zh-Hant.json index 241ad34cc053..1d601348b95b 100644 --- a/src/renderer/src/i18n/locales/zh-Hant.json +++ b/src/renderer/src/i18n/locales/zh-Hant.json @@ -109,6 +109,7 @@ "nameInvalid": "名稱不可包含斜線,也不可為 \".\" / \"..\"", "parentRequired": "必須指定上層目錄", "parentNotAbsolute": "上層目錄必須是絕對路徑", + "parentInaccessible": "無法存取上層目錄:{{detail}}", "targetInaccessible": "無法存取目標路徑:{{detail}}", "targetNotEmpty": "此位置已存在「{{name}}」,且內容不為空。", "targetNotFolder": "此位置已存在「{{name}}」,且不是資料夾。", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index cc9618aa98a6..5ed535c4e8f3 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -20010,6 +20010,7 @@ "nameInvalid": "名称不能包含斜杠,也不能是 \".\" / \"..\"", "parentRequired": "必须指定父目录", "parentNotAbsolute": "父目录必须为绝对路径", + "parentInaccessible": "无法访问父目录:{{detail}}", "targetInaccessible": "无法访问目标路径:{{detail}}", "targetNotEmpty": "此位置已存在“{{name}}”,且不为空。", "targetNotFolder": "此位置已存在“{{name}}”,且不是文件夹。", diff --git a/src/shared/repo-create-failure-copy.ts b/src/shared/repo-create-failure-copy.ts index e066e270136d..714284d97cb8 100644 --- a/src/shared/repo-create-failure-copy.ts +++ b/src/shared/repo-create-failure-copy.ts @@ -4,6 +4,7 @@ export const REPO_CREATE_FAILURE_COPY = { nameInvalid: 'Name cannot contain slashes or be "." / ".."', parentRequired: 'Parent directory is required', parentNotAbsolute: 'Parent directory must be an absolute path', + parentInaccessible: 'Cannot access parent directory: {{detail}}', targetInaccessible: 'Cannot access target path: {{detail}}', targetNotEmpty: '"{{name}}" already exists at this location and is not empty.', targetNotFolder: '"{{name}}" already exists at this location and is not a folder.',