fix(desktop): honor remote working directory for new chats and allow typed paths (#11322)
Co-authored-by: pcace <{ID}+{username}@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
520296328f
commit
70bd9d79bc
@@ -16,7 +16,7 @@ import { ChatState } from '../types/chatState';
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
import { View, ViewOptions } from '../utils/navigationUtils';
|
||||
import { useConfig } from './ConfigContext';
|
||||
import { getInitialWorkingDir } from '../utils/workingDir';
|
||||
import { getEffectiveWorkingDir, getInitialWorkingDir } from '../utils/workingDir';
|
||||
import { createSession } from '../sessions';
|
||||
import LoadingGoose from './LoadingGoose';
|
||||
import { UserInput } from '../types/message';
|
||||
@@ -57,12 +57,25 @@ export default function Hub({
|
||||
const intl = useIntl();
|
||||
const { extensionsList } = useConfig();
|
||||
const [workingDir, setWorkingDir] = useState(getInitialWorkingDir());
|
||||
const userSelectedWorkingDirRef = useRef(false);
|
||||
const [isCreatingSession, setIsCreatingSession] = useState(false);
|
||||
const [nextChatExtensionDraft, setNextChatExtensionDraft] =
|
||||
useState<NextChatExtensionDraft | null>(null);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const { time, meridiem, hour } = useClock();
|
||||
|
||||
// Re-resolve the working dir on mount: GOOSE_WORKING_DIR is fixed at window
|
||||
// creation, so a configured remote directory may have changed since then.
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
void getEffectiveWorkingDir().then((dir) => {
|
||||
if (active && !userSelectedWorkingDirRef.current) setWorkingDir(dir);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const greeting = useMemo(() => {
|
||||
if (hour < 12) return intl.formatMessage(i18n.goodMorning);
|
||||
if (hour < 18) return intl.formatMessage(i18n.goodAfternoon);
|
||||
@@ -86,6 +99,11 @@ export default function Hub({
|
||||
setNextChatExtensionDraft(draft);
|
||||
}, []);
|
||||
|
||||
const handleWorkingDirChange = useCallback((dir: string) => {
|
||||
userSelectedWorkingDirRef.current = true;
|
||||
setWorkingDir(dir);
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async (input: UserInput) => {
|
||||
const { msg: userMessage, images } = input;
|
||||
if (!(images.length > 0 || userMessage.trim()) || isCreatingSession) return;
|
||||
@@ -101,7 +119,10 @@ export default function Hub({
|
||||
? { extensionConfigs: selectedExtensions }
|
||||
: { allExtensions: extensionsList };
|
||||
|
||||
const session = await createSession(workingDir, sessionOptions);
|
||||
// Resolve the effective directory at submit time: the IPC lookup may still
|
||||
// be pending when the user submits, and an explicit pick must win.
|
||||
const dir = userSelectedWorkingDirRef.current ? workingDir : await getEffectiveWorkingDir();
|
||||
const session = await createSession(dir, sessionOptions);
|
||||
setNextChatExtensionDraft(null);
|
||||
|
||||
window.dispatchEvent(new CustomEvent(AppEvents.SESSION_CREATED));
|
||||
@@ -149,7 +170,8 @@ export default function Hub({
|
||||
onFilesProcessed={() => {}}
|
||||
messages={[]}
|
||||
disableAnimation={false}
|
||||
onWorkingDirChange={setWorkingDir}
|
||||
workingDir={workingDir}
|
||||
onWorkingDirChange={handleWorkingDirChange}
|
||||
inputRef={inputRef}
|
||||
nextChatExtensionDraft={draftForMenu}
|
||||
onNextChatExtensionDraftChange={handleNextChatExtensionDraftChange}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Check, FolderDot, FolderOpen, GitBranch, Plus } from 'lucide-react';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/Tooltip';
|
||||
import { Input } from '../ui/input';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -41,8 +42,23 @@ const i18n = defineMessages({
|
||||
id: 'dirSwitcher.noWorktreesFound',
|
||||
defaultMessage: 'No worktrees found',
|
||||
},
|
||||
enterPath: {
|
||||
id: 'dirSwitcher.enterPath',
|
||||
defaultMessage: 'Enter path',
|
||||
},
|
||||
enterPathPlaceholder: {
|
||||
id: 'dirSwitcher.enterPathPlaceholder',
|
||||
defaultMessage: 'Enter an absolute path (e.g. /home/goose/workspace)',
|
||||
},
|
||||
enterPathInvalid: {
|
||||
id: 'dirSwitcher.enterPathInvalid',
|
||||
defaultMessage: 'Working directory must be an absolute path',
|
||||
},
|
||||
});
|
||||
|
||||
const isAbsolutePath = (p: string): boolean =>
|
||||
p.startsWith('/') || p.startsWith('\\\\') || /^[A-Za-z]:[\\/]/.test(p);
|
||||
|
||||
const splitDirPath = (dir: string): { name: string; parent: string } => {
|
||||
const normalized = dir.replace(/[\\/]+$/, '');
|
||||
const parts = normalized.split(/[\\/]/);
|
||||
@@ -84,6 +100,7 @@ export const DirSwitcher: React.FC<DirSwitcherProps> = ({
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const [recentDirs, setRecentDirs] = useState<string[]>([]);
|
||||
const [worktreeDirs, setWorktreeDirs] = useState<string[]>([]);
|
||||
const [customDirInput, setCustomDirInput] = useState('');
|
||||
const refreshVersionRef = useRef(0);
|
||||
|
||||
const refreshMenuData = useCallback(async () => {
|
||||
@@ -111,9 +128,6 @@ export const DirSwitcher: React.FC<DirSwitcherProps> = ({
|
||||
}, [isMenuOpen, refreshMenuData]);
|
||||
|
||||
const applyDirectoryChange = async (newDir: string) => {
|
||||
window.electron.addRecentDir(newDir);
|
||||
setRecentDirs((previous) => [newDir, ...previous.filter((dir) => dir !== newDir)].slice(0, 10));
|
||||
|
||||
if (sessionId) {
|
||||
onRestartStart?.();
|
||||
|
||||
@@ -122,12 +136,18 @@ export const DirSwitcher: React.FC<DirSwitcherProps> = ({
|
||||
} catch (error) {
|
||||
console.error('[DirSwitcher] Failed to update working directory:', error);
|
||||
toast.error(intl.formatMessage(i18n.failedToUpdateWorkingDir));
|
||||
return;
|
||||
} finally {
|
||||
onRestartEnd?.();
|
||||
}
|
||||
} else {
|
||||
await onWorkingDirChange?.(newDir);
|
||||
}
|
||||
|
||||
// Only record the directory after the backend confirmed the change, so a
|
||||
// rejected path does not pollute the recent-directories list.
|
||||
window.electron.addRecentDir(newDir);
|
||||
setRecentDirs((previous) => [newDir, ...previous.filter((dir) => dir !== newDir)].slice(0, 10));
|
||||
};
|
||||
|
||||
const handleDirectoryChange = async () => {
|
||||
@@ -218,6 +238,31 @@ export const DirSwitcher: React.FC<DirSwitcherProps> = ({
|
||||
<Check className="ml-auto h-4 w-4 flex-shrink-0" />
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel>{intl.formatMessage(i18n.enterPath)}</DropdownMenuLabel>
|
||||
<div className="px-2 py-1.5">
|
||||
<Input
|
||||
value={customDirInput}
|
||||
placeholder={intl.formatMessage(i18n.enterPathPlaceholder)}
|
||||
onChange={(e) => setCustomDirInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
// Stop Radix menu typeahead from stealing focus while typing
|
||||
// a path (e.g. the initial "C" of a Windows drive path).
|
||||
e.stopPropagation();
|
||||
if (e.key === 'Enter' && customDirInput.trim()) {
|
||||
const newDir = customDirInput.trim();
|
||||
if (!isAbsolutePath(newDir)) {
|
||||
toast.error(intl.formatMessage(i18n.enterPathInvalid));
|
||||
return;
|
||||
}
|
||||
setCustomDirInput('');
|
||||
setIsMenuOpen(false);
|
||||
void applyDirectoryChange(newDir);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel>{intl.formatMessage(i18n.gitWorktrees)}</DropdownMenuLabel>
|
||||
{filteredWorktreeDirs.length > 0 ? (
|
||||
|
||||
@@ -917,6 +917,16 @@
|
||||
"dirSwitcher.currentDirectory": {
|
||||
"defaultMessage": "Aktuelles Verzeichnis"
|
||||
},
|
||||
"dirSwitcher.enterPath": {
|
||||
"defaultMessage": "Pfad eingeben"
|
||||
},
|
||||
"dirSwitcher.enterPathPlaceholder": {
|
||||
"defaultMessage": "Absoluten Pfad eingeben (z. B. /home/goose/workspace)"
|
||||
},
|
||||
"dirSwitcher.enterPathInvalid": {
|
||||
"defaultMessage": "Arbeitsverzeichnis muss ein absoluter Pfad sein"
|
||||
},
|
||||
|
||||
"dirSwitcher.failedToUpdateWorkingDir": {
|
||||
"defaultMessage": "Aktualisieren des Arbeitsverzeichnisses fehlgeschlagen"
|
||||
},
|
||||
|
||||
@@ -938,6 +938,15 @@
|
||||
"dirSwitcher.currentDirectory": {
|
||||
"defaultMessage": "Current directory"
|
||||
},
|
||||
"dirSwitcher.enterPath": {
|
||||
"defaultMessage": "Enter path"
|
||||
},
|
||||
"dirSwitcher.enterPathInvalid": {
|
||||
"defaultMessage": "Working directory must be an absolute path"
|
||||
},
|
||||
"dirSwitcher.enterPathPlaceholder": {
|
||||
"defaultMessage": "Enter an absolute path (e.g. /home/goose/workspace)"
|
||||
},
|
||||
"dirSwitcher.failedToUpdateWorkingDir": {
|
||||
"defaultMessage": "Failed to update working directory"
|
||||
},
|
||||
|
||||
@@ -917,6 +917,17 @@
|
||||
"dirSwitcher.currentDirectory": {
|
||||
"defaultMessage": "Directorio actual"
|
||||
},
|
||||
"dirSwitcher.enterPath": {
|
||||
"defaultMessage": "Enter path"
|
||||
},
|
||||
"dirSwitcher.enterPathPlaceholder": {
|
||||
"defaultMessage": "Enter an absolute path (e.g. /home/goose/workspace)"
|
||||
},
|
||||
"dirSwitcher.enterPathInvalid": {
|
||||
"defaultMessage": "Working directory must be an absolute path"
|
||||
},
|
||||
|
||||
|
||||
"dirSwitcher.failedToUpdateWorkingDir": {
|
||||
"defaultMessage": "No se pudo actualizar el directorio de trabajo"
|
||||
},
|
||||
|
||||
@@ -917,6 +917,17 @@
|
||||
"dirSwitcher.currentDirectory": {
|
||||
"defaultMessage": "Répertoire actuel"
|
||||
},
|
||||
"dirSwitcher.enterPath": {
|
||||
"defaultMessage": "Enter path"
|
||||
},
|
||||
"dirSwitcher.enterPathPlaceholder": {
|
||||
"defaultMessage": "Enter an absolute path (e.g. /home/goose/workspace)"
|
||||
},
|
||||
"dirSwitcher.enterPathInvalid": {
|
||||
"defaultMessage": "Working directory must be an absolute path"
|
||||
},
|
||||
|
||||
|
||||
"dirSwitcher.failedToUpdateWorkingDir": {
|
||||
"defaultMessage": "Échec de la mise à jour du répertoire de travail"
|
||||
},
|
||||
|
||||
@@ -917,6 +917,17 @@
|
||||
"dirSwitcher.currentDirectory": {
|
||||
"defaultMessage": "वर्तमान निर्देशिका"
|
||||
},
|
||||
"dirSwitcher.enterPath": {
|
||||
"defaultMessage": "Enter path"
|
||||
},
|
||||
"dirSwitcher.enterPathPlaceholder": {
|
||||
"defaultMessage": "Enter an absolute path (e.g. /home/goose/workspace)"
|
||||
},
|
||||
"dirSwitcher.enterPathInvalid": {
|
||||
"defaultMessage": "Working directory must be an absolute path"
|
||||
},
|
||||
|
||||
|
||||
"dirSwitcher.failedToUpdateWorkingDir": {
|
||||
"defaultMessage": "कार्यशील निर्देशिका अद्यतन करने में विफल"
|
||||
},
|
||||
|
||||
@@ -917,6 +917,17 @@
|
||||
"dirSwitcher.currentDirectory": {
|
||||
"defaultMessage": "Direktori saat ini"
|
||||
},
|
||||
"dirSwitcher.enterPath": {
|
||||
"defaultMessage": "Enter path"
|
||||
},
|
||||
"dirSwitcher.enterPathPlaceholder": {
|
||||
"defaultMessage": "Enter an absolute path (e.g. /home/goose/workspace)"
|
||||
},
|
||||
"dirSwitcher.enterPathInvalid": {
|
||||
"defaultMessage": "Working directory must be an absolute path"
|
||||
},
|
||||
|
||||
|
||||
"dirSwitcher.failedToUpdateWorkingDir": {
|
||||
"defaultMessage": "Gagal memperbarui direktori kerja"
|
||||
},
|
||||
|
||||
@@ -917,6 +917,17 @@
|
||||
"dirSwitcher.currentDirectory": {
|
||||
"defaultMessage": "Cartella corrente"
|
||||
},
|
||||
"dirSwitcher.enterPath": {
|
||||
"defaultMessage": "Enter path"
|
||||
},
|
||||
"dirSwitcher.enterPathPlaceholder": {
|
||||
"defaultMessage": "Enter an absolute path (e.g. /home/goose/workspace)"
|
||||
},
|
||||
"dirSwitcher.enterPathInvalid": {
|
||||
"defaultMessage": "Working directory must be an absolute path"
|
||||
},
|
||||
|
||||
|
||||
"dirSwitcher.failedToUpdateWorkingDir": {
|
||||
"defaultMessage": "Impossibile aggiornare la cartella di lavoro"
|
||||
},
|
||||
|
||||
@@ -917,6 +917,17 @@
|
||||
"dirSwitcher.currentDirectory": {
|
||||
"defaultMessage": "現在のディレクトリ"
|
||||
},
|
||||
"dirSwitcher.enterPath": {
|
||||
"defaultMessage": "Enter path"
|
||||
},
|
||||
"dirSwitcher.enterPathPlaceholder": {
|
||||
"defaultMessage": "Enter an absolute path (e.g. /home/goose/workspace)"
|
||||
},
|
||||
"dirSwitcher.enterPathInvalid": {
|
||||
"defaultMessage": "Working directory must be an absolute path"
|
||||
},
|
||||
|
||||
|
||||
"dirSwitcher.failedToUpdateWorkingDir": {
|
||||
"defaultMessage": "作業ディレクトリの更新に失敗しました"
|
||||
},
|
||||
|
||||
@@ -917,6 +917,17 @@
|
||||
"dirSwitcher.currentDirectory": {
|
||||
"defaultMessage": "현재 디렉터리"
|
||||
},
|
||||
"dirSwitcher.enterPath": {
|
||||
"defaultMessage": "Enter path"
|
||||
},
|
||||
"dirSwitcher.enterPathPlaceholder": {
|
||||
"defaultMessage": "Enter an absolute path (e.g. /home/goose/workspace)"
|
||||
},
|
||||
"dirSwitcher.enterPathInvalid": {
|
||||
"defaultMessage": "Working directory must be an absolute path"
|
||||
},
|
||||
|
||||
|
||||
"dirSwitcher.failedToUpdateWorkingDir": {
|
||||
"defaultMessage": "작업 디렉터리를 업데이트하지 못했습니다."
|
||||
},
|
||||
|
||||
@@ -917,6 +917,17 @@
|
||||
"dirSwitcher.currentDirectory": {
|
||||
"defaultMessage": "Direktori semasa"
|
||||
},
|
||||
"dirSwitcher.enterPath": {
|
||||
"defaultMessage": "Enter path"
|
||||
},
|
||||
"dirSwitcher.enterPathPlaceholder": {
|
||||
"defaultMessage": "Enter an absolute path (e.g. /home/goose/workspace)"
|
||||
},
|
||||
"dirSwitcher.enterPathInvalid": {
|
||||
"defaultMessage": "Working directory must be an absolute path"
|
||||
},
|
||||
|
||||
|
||||
"dirSwitcher.failedToUpdateWorkingDir": {
|
||||
"defaultMessage": "Gagal mengemas kini direktori kerja"
|
||||
},
|
||||
|
||||
@@ -917,6 +917,17 @@
|
||||
"dirSwitcher.currentDirectory": {
|
||||
"defaultMessage": "Diretório atual"
|
||||
},
|
||||
"dirSwitcher.enterPath": {
|
||||
"defaultMessage": "Enter path"
|
||||
},
|
||||
"dirSwitcher.enterPathPlaceholder": {
|
||||
"defaultMessage": "Enter an absolute path (e.g. /home/goose/workspace)"
|
||||
},
|
||||
"dirSwitcher.enterPathInvalid": {
|
||||
"defaultMessage": "Working directory must be an absolute path"
|
||||
},
|
||||
|
||||
|
||||
"dirSwitcher.failedToUpdateWorkingDir": {
|
||||
"defaultMessage": "Falha ao atualizar o diretório de trabalho"
|
||||
},
|
||||
|
||||
@@ -917,6 +917,17 @@
|
||||
"dirSwitcher.currentDirectory": {
|
||||
"defaultMessage": "Текущий каталог"
|
||||
},
|
||||
"dirSwitcher.enterPath": {
|
||||
"defaultMessage": "Enter path"
|
||||
},
|
||||
"dirSwitcher.enterPathPlaceholder": {
|
||||
"defaultMessage": "Enter an absolute path (e.g. /home/goose/workspace)"
|
||||
},
|
||||
"dirSwitcher.enterPathInvalid": {
|
||||
"defaultMessage": "Working directory must be an absolute path"
|
||||
},
|
||||
|
||||
|
||||
"dirSwitcher.failedToUpdateWorkingDir": {
|
||||
"defaultMessage": "Не удалось обновить рабочий каталог"
|
||||
},
|
||||
|
||||
@@ -917,6 +917,17 @@
|
||||
"dirSwitcher.currentDirectory": {
|
||||
"defaultMessage": "Geçerli dizin"
|
||||
},
|
||||
"dirSwitcher.enterPath": {
|
||||
"defaultMessage": "Enter path"
|
||||
},
|
||||
"dirSwitcher.enterPathPlaceholder": {
|
||||
"defaultMessage": "Enter an absolute path (e.g. /home/goose/workspace)"
|
||||
},
|
||||
"dirSwitcher.enterPathInvalid": {
|
||||
"defaultMessage": "Working directory must be an absolute path"
|
||||
},
|
||||
|
||||
|
||||
"dirSwitcher.failedToUpdateWorkingDir": {
|
||||
"defaultMessage": "Çalışma dizini güncellenemedi"
|
||||
},
|
||||
|
||||
@@ -917,6 +917,17 @@
|
||||
"dirSwitcher.currentDirectory": {
|
||||
"defaultMessage": "Thư mục hiện tại"
|
||||
},
|
||||
"dirSwitcher.enterPath": {
|
||||
"defaultMessage": "Enter path"
|
||||
},
|
||||
"dirSwitcher.enterPathPlaceholder": {
|
||||
"defaultMessage": "Enter an absolute path (e.g. /home/goose/workspace)"
|
||||
},
|
||||
"dirSwitcher.enterPathInvalid": {
|
||||
"defaultMessage": "Working directory must be an absolute path"
|
||||
},
|
||||
|
||||
|
||||
"dirSwitcher.failedToUpdateWorkingDir": {
|
||||
"defaultMessage": "Không thể cập nhật thư mục làm việc"
|
||||
},
|
||||
|
||||
@@ -917,6 +917,17 @@
|
||||
"dirSwitcher.currentDirectory": {
|
||||
"defaultMessage": "当前目录"
|
||||
},
|
||||
"dirSwitcher.enterPath": {
|
||||
"defaultMessage": "Enter path"
|
||||
},
|
||||
"dirSwitcher.enterPathPlaceholder": {
|
||||
"defaultMessage": "Enter an absolute path (e.g. /home/goose/workspace)"
|
||||
},
|
||||
"dirSwitcher.enterPathInvalid": {
|
||||
"defaultMessage": "Working directory must be an absolute path"
|
||||
},
|
||||
|
||||
|
||||
"dirSwitcher.failedToUpdateWorkingDir": {
|
||||
"defaultMessage": "更新工作目录失败"
|
||||
},
|
||||
|
||||
@@ -917,6 +917,17 @@
|
||||
"dirSwitcher.currentDirectory": {
|
||||
"defaultMessage": "目前目錄"
|
||||
},
|
||||
"dirSwitcher.enterPath": {
|
||||
"defaultMessage": "Enter path"
|
||||
},
|
||||
"dirSwitcher.enterPathPlaceholder": {
|
||||
"defaultMessage": "Enter an absolute path (e.g. /home/goose/workspace)"
|
||||
},
|
||||
"dirSwitcher.enterPathInvalid": {
|
||||
"defaultMessage": "Working directory must be an absolute path"
|
||||
},
|
||||
|
||||
|
||||
"dirSwitcher.failedToUpdateWorkingDir": {
|
||||
"defaultMessage": "無法更新工作目錄"
|
||||
},
|
||||
|
||||
@@ -988,6 +988,11 @@ let appConfig = {
|
||||
GOOSE_PREDEFINED_MODELS: predefinedModels,
|
||||
GOOSE_PATH_ROOT: sanitizeGoosePathRoot(process.env),
|
||||
GOOSE_WORKING_DIR: '',
|
||||
// Whether the window is bound to an external backend (fixed at window
|
||||
// creation via gooseServeLeases) and which URL it is bound to.
|
||||
GOOSE_EXTERNAL_BACKEND: false,
|
||||
GOOSE_EXTERNAL_BACKEND_URL: '',
|
||||
GOOSE_EXTERNAL_BACKEND_SOURCE: '',
|
||||
// Start with the env-var override; the OS region locale is filled in after app.ready
|
||||
// (see updateLocaleFromSystem below) since getSystemLocale() cannot be called earlier.
|
||||
GOOSE_LOCALE: process.env.GOOSE_LOCALE || undefined,
|
||||
@@ -1310,6 +1315,9 @@ const createChat = async (
|
||||
...appConfig,
|
||||
GOOSE_LOCALE: getConfiguredGooseLocale(),
|
||||
GOOSE_WORKING_DIR: workingDir,
|
||||
GOOSE_EXTERNAL_BACKEND: externalBackend !== null,
|
||||
GOOSE_EXTERNAL_BACKEND_URL: externalBackend?.url ?? '',
|
||||
GOOSE_EXTERNAL_BACKEND_SOURCE: externalBackend?.source ?? '',
|
||||
REQUEST_DIR: dir,
|
||||
GOOSE_VERSION: version,
|
||||
recipeDeeplink: recipeDeeplink,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { resolveWorkingDir } from '../workingDir';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { getEffectiveWorkingDir, resolveWorkingDir } from '../workingDir';
|
||||
|
||||
describe('resolveWorkingDir', () => {
|
||||
it('uses the configured external backend directory when present', () => {
|
||||
@@ -10,3 +10,87 @@ describe('resolveWorkingDir', () => {
|
||||
expect(resolveWorkingDir(undefined, undefined, 'C:\\Users\\goose')).toBe('C:\\Users\\goose');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getEffectiveWorkingDir', () => {
|
||||
const getSettingMock = vi.fn();
|
||||
const appConfigGetMock = vi.fn();
|
||||
|
||||
const mockWindow = (externalBackend: boolean, boundUrl: string, source = 'settings') => {
|
||||
appConfigGetMock.mockImplementation((key: string) => {
|
||||
if (key === 'GOOSE_EXTERNAL_BACKEND') return externalBackend;
|
||||
if (key === 'GOOSE_EXTERNAL_BACKEND_URL') return boundUrl;
|
||||
if (key === 'GOOSE_EXTERNAL_BACKEND_SOURCE') return source;
|
||||
if (key === 'GOOSE_WORKING_DIR') return '/Users/johannes/home/workspace';
|
||||
return undefined;
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
getSettingMock.mockReset();
|
||||
appConfigGetMock.mockReset();
|
||||
(globalThis as Record<string, unknown>).window = {
|
||||
appConfig: { get: appConfigGetMock },
|
||||
electron: { getSetting: getSettingMock },
|
||||
} as unknown as typeof globalThis;
|
||||
});
|
||||
|
||||
it('prefers the configured remote directory when bound to the matching external backend', async () => {
|
||||
mockWindow(true, 'http://remote:3000/');
|
||||
getSettingMock.mockResolvedValue({
|
||||
enabled: true,
|
||||
url: 'http://remote:3000',
|
||||
workingDir: ' /home/goose/workspace ',
|
||||
});
|
||||
await expect(getEffectiveWorkingDir()).resolves.toBe('/home/goose/workspace');
|
||||
});
|
||||
|
||||
it('honors the configured remote directory for env-mode backends regardless of enabled/url', async () => {
|
||||
mockWindow(true, 'http://env-backend:3000', 'env');
|
||||
getSettingMock.mockResolvedValue({
|
||||
enabled: false,
|
||||
url: 'http://unrelated:4000',
|
||||
workingDir: '/home/goose/workspace',
|
||||
});
|
||||
await expect(getEffectiveWorkingDir()).resolves.toBe('/home/goose/workspace');
|
||||
});
|
||||
|
||||
it('falls back to the remembered directory for env-mode backends without a configured dir', async () => {
|
||||
mockWindow(true, 'http://env-backend:3000', 'env');
|
||||
getSettingMock.mockResolvedValue({ enabled: false, workingDir: ' ' });
|
||||
await expect(getEffectiveWorkingDir()).resolves.toBe('/Users/johannes/home/workspace');
|
||||
});
|
||||
|
||||
it('ignores the remote directory when the window is bound to the local backend', async () => {
|
||||
mockWindow(false, '');
|
||||
getSettingMock.mockResolvedValue({ enabled: true, workingDir: '/home/goose/workspace' });
|
||||
await expect(getEffectiveWorkingDir()).resolves.toBe('/Users/johannes/home/workspace');
|
||||
});
|
||||
|
||||
it('falls back to the remembered directory when the bound backend no longer matches settings', async () => {
|
||||
mockWindow(true, 'http://server-a:3000');
|
||||
getSettingMock.mockResolvedValue({
|
||||
enabled: true,
|
||||
url: 'http://server-b:3000',
|
||||
workingDir: '/home/goose/workspace',
|
||||
});
|
||||
await expect(getEffectiveWorkingDir()).resolves.toBe('/Users/johannes/home/workspace');
|
||||
});
|
||||
|
||||
it('falls back to the remembered directory when the external backend is disabled', async () => {
|
||||
mockWindow(true, 'http://remote:3000');
|
||||
getSettingMock.mockResolvedValue({ enabled: false, workingDir: '/home/goose/workspace' });
|
||||
await expect(getEffectiveWorkingDir()).resolves.toBe('/Users/johannes/home/workspace');
|
||||
});
|
||||
|
||||
it('falls back to the remembered directory when the remote directory is blank', async () => {
|
||||
mockWindow(true, 'http://remote:3000');
|
||||
getSettingMock.mockResolvedValue({ enabled: true, workingDir: ' ' });
|
||||
await expect(getEffectiveWorkingDir()).resolves.toBe('/Users/johannes/home/workspace');
|
||||
});
|
||||
|
||||
it('falls back to the remembered directory when the setting cannot be read', async () => {
|
||||
mockWindow(true, 'http://remote:3000');
|
||||
getSettingMock.mockRejectedValue(new Error('settings unavailable'));
|
||||
await expect(getEffectiveWorkingDir()).resolves.toBe('/Users/johannes/home/workspace');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,55 @@ export const getInitialWorkingDir = (): string => {
|
||||
return (window.appConfig?.get('GOOSE_WORKING_DIR') as string) ?? '';
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve the working directory for a new chat in the current window.
|
||||
*
|
||||
* GOOSE_WORKING_DIR is fixed when the window is created, so it goes stale when
|
||||
* the user switches to an external backend (or changes the configured remote
|
||||
* directory) afterwards. The configured remote directory is only applied when
|
||||
* the window is actually bound to an external backend (fixed at window creation
|
||||
* via the gooseServeLeases) and that backend still matches the current
|
||||
* settings; otherwise the remote path would be sent to the local (or a
|
||||
* different remote) server, where it fails the cwd existence validation.
|
||||
* Editing the remote working directory in settings still takes effect for new
|
||||
* chats in the same window. Env-mode backends (GOOSE_EXTERNAL_BACKEND) always
|
||||
* use the configured directory (matching getActiveExternalBackend), while
|
||||
* settings-mode backends require the window-bound backend to still match.
|
||||
*/
|
||||
export const getEffectiveWorkingDir = async (): Promise<string> => {
|
||||
const initial = getInitialWorkingDir();
|
||||
const boundUrl = window.appConfig?.get('GOOSE_EXTERNAL_BACKEND_URL') as string | undefined;
|
||||
const source = window.appConfig?.get('GOOSE_EXTERNAL_BACKEND_SOURCE') as string | undefined;
|
||||
if (window.appConfig?.get('GOOSE_EXTERNAL_BACKEND') !== true || !boundUrl) {
|
||||
return initial;
|
||||
}
|
||||
try {
|
||||
const external = await window.electron.getSetting('externalGoosed');
|
||||
const remote = external?.workingDir?.trim();
|
||||
if (!remote) {
|
||||
return initial;
|
||||
}
|
||||
// Env-mode backends use settings.externalGoosed.workingDir regardless of the
|
||||
// enabled flag or URL (see getActiveExternalBackend); settings-mode requires
|
||||
// the backend to still match the window-bound URL.
|
||||
if (source === 'env') {
|
||||
return remote;
|
||||
}
|
||||
if (
|
||||
external?.enabled &&
|
||||
external?.url &&
|
||||
normalizeUrl(boundUrl) === normalizeUrl(external.url)
|
||||
) {
|
||||
return remote;
|
||||
}
|
||||
} catch {
|
||||
// Settings unavailable; fall back to the remembered directory.
|
||||
}
|
||||
return initial;
|
||||
};
|
||||
|
||||
const normalizeUrl = (url: string): string => url.trim().replace(/\/+$/, '');
|
||||
|
||||
export const resolveWorkingDir = (
|
||||
externalWorkingDir: string | undefined,
|
||||
requestedWorkingDir: string | undefined,
|
||||
|
||||
Reference in New Issue
Block a user