mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-07 15:55:50 +00:00
* feat(web-shell): add native workspace folder picker * fix(serve): harden native directory picker and add coverage (#7849) Add a 5-minute timeout to each native picker subprocess and to the webui action so a dismissed dialog cannot leave an orphaned GUI process, and distinguish a headless Linux "cannot open display" failure from a deliberate zenity cancellation. Cover pickNativeDirectory's platform branches, the route's 501/500 error paths, and the dialog's picker failure path with focused tests. * fix(serve): log directory picker failures to daemon stderr (#7849) * fix(serve): abort directory picker on client disconnect and stagger timeouts (#7849) * test(webui): add unit tests for pickWorkspaceDirectory action (#7849) * fix(serve): set UTF-8 console encoding for PowerShell picker and treat timeout kills as cancels (#7849) * fix(serve): abort directory picker on response close, not request close (#7849) --------- Co-authored-by: qwen-code-dev-bot <qwen-code-dev@service.alibaba.com> Co-authored-by: Qwen Code Bot <qwen-code-bot@users.noreply.github.com> Co-authored-by: Qwen Code CI <qwen-code-ci@users.noreply.github.com>
411 lines
14 KiB
TypeScript
411 lines
14 KiB
TypeScript
import { useState, useCallback, useRef, useEffect } from 'react';
|
|
import { useI18n } from '../../i18n';
|
|
import { DialogShell } from './DialogShell';
|
|
import { Button } from '../ui/button';
|
|
import {
|
|
Field,
|
|
FieldContent,
|
|
FieldDescription,
|
|
FieldError,
|
|
FieldGroup,
|
|
FieldLabel,
|
|
} from '../ui/field';
|
|
import { Input } from '../ui/input';
|
|
import { Switch } from '../ui/switch';
|
|
import { FolderOpenIcon } from 'lucide-react';
|
|
|
|
export interface WorkspacePathSuggestion {
|
|
name: string;
|
|
path: string;
|
|
}
|
|
|
|
export interface WorkspacePathSuggestions {
|
|
dir: string;
|
|
sep: string;
|
|
suggestions: WorkspacePathSuggestion[];
|
|
truncated: boolean;
|
|
}
|
|
|
|
interface AddWorkspaceDialogProps {
|
|
onClose: () => void;
|
|
onAdd: (cwd: string, persist: boolean, displayName?: string) => Promise<void>;
|
|
displayNameEnabled?: boolean;
|
|
/**
|
|
* Directory autocomplete backend. When provided, typing an absolute path
|
|
* surfaces matching subdirectories in a listbox under the input.
|
|
*/
|
|
onSuggest?: (prefix: string) => Promise<WorkspacePathSuggestions>;
|
|
onPick?: () => Promise<string | undefined>;
|
|
persistenceSupported?: boolean;
|
|
}
|
|
|
|
const HINT_ID = 'add-workspace-hint';
|
|
const DISPLAY_NAME_HINT_ID = 'add-workspace-display-name-hint';
|
|
const ERROR_ID = 'add-workspace-error';
|
|
const LISTBOX_ID = 'add-workspace-suggestions';
|
|
const SUGGEST_DEBOUNCE_MS = 150;
|
|
|
|
function isAbsoluteLike(value: string): boolean {
|
|
return value.startsWith('/') || /^[A-Za-z]:[\\/]/.test(value);
|
|
}
|
|
|
|
export function AddWorkspaceDialog({
|
|
onClose,
|
|
onAdd,
|
|
displayNameEnabled = false,
|
|
onSuggest,
|
|
onPick,
|
|
persistenceSupported = true,
|
|
}: AddWorkspaceDialogProps) {
|
|
const { t } = useI18n();
|
|
const [path, setPath] = useState('');
|
|
const [displayName, setDisplayName] = useState('');
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [submitting, setSubmitting] = useState(false);
|
|
const [persist, setPersist] = useState(true);
|
|
const [suggestions, setSuggestions] = useState<WorkspacePathSuggestion[]>([]);
|
|
const [listOpen, setListOpen] = useState(false);
|
|
const [highlight, setHighlight] = useState(-1);
|
|
const [hostSep, setHostSep] = useState('/');
|
|
const [browsing, setBrowsing] = useState(false);
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
const listOpenRef = useRef(false);
|
|
listOpenRef.current = listOpen && suggestions.length > 0;
|
|
const suggestSeqRef = useRef(0);
|
|
// Set when a suggestion is accepted or the list is dismissed, so the
|
|
// path-change effect knows whether to reopen the list for that update.
|
|
const suppressNextFetchOpenRef = useRef(false);
|
|
|
|
useEffect(() => {
|
|
inputRef.current?.focus();
|
|
}, []);
|
|
|
|
const closeList = useCallback(() => {
|
|
setListOpen(false);
|
|
setHighlight(-1);
|
|
}, []);
|
|
|
|
// Debounced suggestion fetch, keyed off the current path value. A stale
|
|
// response (older sequence number) never overwrites a newer one.
|
|
useEffect(() => {
|
|
if (!onSuggest) return undefined;
|
|
if (!isAbsoluteLike(path)) {
|
|
setSuggestions([]);
|
|
closeList();
|
|
return undefined;
|
|
}
|
|
const seq = ++suggestSeqRef.current;
|
|
const openOnResult = !suppressNextFetchOpenRef.current;
|
|
suppressNextFetchOpenRef.current = false;
|
|
const timer = setTimeout(() => {
|
|
onSuggest(path).then(
|
|
(result) => {
|
|
if (seq !== suggestSeqRef.current) return;
|
|
setSuggestions(result.suggestions);
|
|
setHostSep(result.sep || '/');
|
|
setHighlight(-1);
|
|
if (openOnResult || listOpenRef.current) {
|
|
setListOpen(result.suggestions.length > 0);
|
|
}
|
|
},
|
|
() => {
|
|
if (seq !== suggestSeqRef.current) return;
|
|
// Autocomplete is best-effort; a failed lookup just yields no list.
|
|
setSuggestions([]);
|
|
closeList();
|
|
},
|
|
);
|
|
}, SUGGEST_DEBOUNCE_MS);
|
|
return () => clearTimeout(timer);
|
|
}, [path, onSuggest, closeList]);
|
|
|
|
// Radix listens for Escape on document capture; intercept one step earlier
|
|
// (window capture) so an open suggestion list consumes the Escape instead
|
|
// of closing the whole dialog (DialogShell skips `defaultPrevented`).
|
|
useEffect(() => {
|
|
const handler = (event: KeyboardEvent) => {
|
|
if (event.key !== 'Escape' || !listOpenRef.current) return;
|
|
if (event.isComposing || event.keyCode === 229) return;
|
|
event.preventDefault();
|
|
closeList();
|
|
};
|
|
window.addEventListener('keydown', handler, { capture: true });
|
|
return () =>
|
|
window.removeEventListener('keydown', handler, { capture: true });
|
|
}, [closeList]);
|
|
|
|
const acceptSuggestion = useCallback(
|
|
(suggestion: WorkspacePathSuggestion) => {
|
|
// Append the host separator so the next keystroke (or the immediate
|
|
// refetch below) descends into the accepted directory.
|
|
suppressNextFetchOpenRef.current = false;
|
|
setPath(suggestion.path + hostSep);
|
|
setError(null);
|
|
closeList();
|
|
inputRef.current?.focus();
|
|
},
|
|
[hostSep, closeList],
|
|
);
|
|
|
|
const pickDirectory = useCallback(async () => {
|
|
if (!onPick) return;
|
|
setBrowsing(true);
|
|
setError(null);
|
|
try {
|
|
const selectedPath = await onPick();
|
|
if (selectedPath) {
|
|
++suggestSeqRef.current;
|
|
setPath(selectedPath);
|
|
setSuggestions([]);
|
|
closeList();
|
|
}
|
|
} catch {
|
|
setError(t('sidebar.addWorkspaceBrowseError'));
|
|
} finally {
|
|
setBrowsing(false);
|
|
}
|
|
}, [onPick, closeList, t]);
|
|
|
|
const handleInputKeyDown = useCallback(
|
|
(event: React.KeyboardEvent<HTMLInputElement>) => {
|
|
const open = listOpen && suggestions.length > 0;
|
|
if (event.key === 'ArrowDown') {
|
|
event.preventDefault();
|
|
if (!open) {
|
|
if (suggestions.length > 0) setListOpen(true);
|
|
return;
|
|
}
|
|
setHighlight((current) => (current + 1) % suggestions.length);
|
|
return;
|
|
}
|
|
if (event.key === 'ArrowUp') {
|
|
if (!open) return;
|
|
event.preventDefault();
|
|
setHighlight(
|
|
(current) => (current <= 0 ? suggestions.length : current) - 1,
|
|
);
|
|
return;
|
|
}
|
|
if (event.key === 'Tab' && open && !event.shiftKey) {
|
|
const target =
|
|
highlight >= 0
|
|
? suggestions[highlight]
|
|
: suggestions.length === 1
|
|
? suggestions[0]
|
|
: undefined;
|
|
if (target) {
|
|
event.preventDefault();
|
|
acceptSuggestion(target);
|
|
}
|
|
return;
|
|
}
|
|
if (event.key === 'Enter' && open && highlight >= 0) {
|
|
// Enter accepts the highlighted directory instead of submitting.
|
|
event.preventDefault();
|
|
acceptSuggestion(suggestions[highlight]);
|
|
}
|
|
},
|
|
[listOpen, suggestions, highlight, acceptSuggestion],
|
|
);
|
|
|
|
const handleSubmit = useCallback(
|
|
async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
const trimmed = path.trim();
|
|
if (!trimmed) return;
|
|
if (!isAbsoluteLike(trimmed)) {
|
|
setError(t('sidebar.addWorkspaceAbsError'));
|
|
return;
|
|
}
|
|
setError(null);
|
|
setSubmitting(true);
|
|
closeList();
|
|
try {
|
|
const effectivePersist = persistenceSupported ? persist : false;
|
|
const trimmedDisplayName = displayNameEnabled ? displayName.trim() : '';
|
|
if (trimmedDisplayName) {
|
|
await onAdd(trimmed, effectivePersist, trimmedDisplayName);
|
|
} else {
|
|
await onAdd(trimmed, effectivePersist);
|
|
}
|
|
onClose();
|
|
} catch (err) {
|
|
setError(
|
|
err instanceof Error ? err.message : t('sidebar.addWorkspaceError'),
|
|
);
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
},
|
|
[
|
|
path,
|
|
displayName,
|
|
displayNameEnabled,
|
|
persist,
|
|
persistenceSupported,
|
|
onAdd,
|
|
onClose,
|
|
closeList,
|
|
t,
|
|
],
|
|
);
|
|
|
|
const showList = listOpen && suggestions.length > 0;
|
|
|
|
return (
|
|
<DialogShell
|
|
title={t('sidebar.addWorkspaceTitle')}
|
|
size="md"
|
|
onClose={onClose}
|
|
>
|
|
<form className="flex flex-col gap-6" onSubmit={handleSubmit}>
|
|
<FieldGroup>
|
|
<Field data-invalid={error ? true : undefined}>
|
|
<FieldLabel htmlFor="add-workspace-path">
|
|
{t('sidebar.addWorkspacePath')}
|
|
</FieldLabel>
|
|
<div className="relative">
|
|
<div className="flex gap-2">
|
|
<Input
|
|
ref={inputRef}
|
|
id="add-workspace-path"
|
|
type="text"
|
|
placeholder="/absolute/path/to/project"
|
|
value={path}
|
|
onChange={(e) => {
|
|
setPath(e.target.value);
|
|
if (error) setError(null);
|
|
}}
|
|
onKeyDown={handleInputKeyDown}
|
|
onBlur={() => {
|
|
// Delay so a mousedown on a suggestion wins over blur.
|
|
setTimeout(() => {
|
|
suppressNextFetchOpenRef.current = true;
|
|
closeList();
|
|
}, 100);
|
|
}}
|
|
disabled={submitting || browsing}
|
|
autoCapitalize="off"
|
|
autoCorrect="off"
|
|
autoComplete="off"
|
|
spellCheck={false}
|
|
role="combobox"
|
|
aria-expanded={showList}
|
|
aria-controls={showList ? LISTBOX_ID : undefined}
|
|
aria-activedescendant={
|
|
showList && highlight >= 0
|
|
? `${LISTBOX_ID}-${highlight}`
|
|
: undefined
|
|
}
|
|
aria-describedby={error ? `${ERROR_ID} ${HINT_ID}` : HINT_ID}
|
|
aria-invalid={error ? true : undefined}
|
|
/>
|
|
{onPick && (
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
onMouseDown={(event) => event.preventDefault()}
|
|
onClick={() => void pickDirectory()}
|
|
disabled={submitting || browsing}
|
|
>
|
|
<FolderOpenIcon aria-hidden="true" />
|
|
{t('sidebar.addWorkspaceBrowse')}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
{showList && (
|
|
<ul
|
|
id={LISTBOX_ID}
|
|
role="listbox"
|
|
aria-label={t('sidebar.addWorkspaceSuggestions')}
|
|
className="absolute inset-x-0 top-full z-50 mt-1 max-h-56 overflow-y-auto rounded-md border bg-popover py-1 text-popover-foreground shadow-md"
|
|
>
|
|
{suggestions.map((suggestion, index) => (
|
|
<li
|
|
key={suggestion.path}
|
|
id={`${LISTBOX_ID}-${index}`}
|
|
role="option"
|
|
aria-selected={index === highlight}
|
|
className={`cursor-pointer truncate px-3 py-1.5 ${
|
|
index === highlight
|
|
? 'bg-accent text-accent-foreground'
|
|
: 'hover:bg-accent/50'
|
|
}`}
|
|
onMouseDown={(event) => {
|
|
// Keep focus in the input; blur would close the list
|
|
// before click lands.
|
|
event.preventDefault();
|
|
acceptSuggestion(suggestion);
|
|
}}
|
|
onMouseEnter={() => setHighlight(index)}
|
|
>
|
|
{suggestion.name}
|
|
<span className="text-muted-foreground">{hostSep}</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
<FieldDescription id={HINT_ID}>
|
|
{t('sidebar.addWorkspaceHint')}
|
|
</FieldDescription>
|
|
{error && <FieldError id={ERROR_ID}>{error}</FieldError>}
|
|
</Field>
|
|
{displayNameEnabled && (
|
|
<Field>
|
|
<FieldLabel htmlFor="add-workspace-display-name">
|
|
{t('sidebar.addWorkspaceDisplayName')}
|
|
</FieldLabel>
|
|
<Input
|
|
id="add-workspace-display-name"
|
|
type="text"
|
|
value={displayName}
|
|
onChange={(event) => setDisplayName(event.target.value)}
|
|
disabled={submitting}
|
|
maxLength={256}
|
|
autoComplete="off"
|
|
aria-describedby={DISPLAY_NAME_HINT_ID}
|
|
/>
|
|
<FieldDescription id={DISPLAY_NAME_HINT_ID}>
|
|
{t('sidebar.addWorkspaceDisplayNameHint')}
|
|
</FieldDescription>
|
|
</Field>
|
|
)}
|
|
{persistenceSupported && (
|
|
<Field orientation="horizontal">
|
|
<FieldContent>
|
|
<FieldLabel htmlFor="add-workspace-persist">
|
|
{t('sidebar.addWorkspacePersist')}
|
|
</FieldLabel>
|
|
<FieldDescription>
|
|
{t('sidebar.addWorkspacePersistHint')}
|
|
</FieldDescription>
|
|
</FieldContent>
|
|
<Switch
|
|
id="add-workspace-persist"
|
|
checked={persist}
|
|
onCheckedChange={setPersist}
|
|
disabled={submitting}
|
|
/>
|
|
</Field>
|
|
)}
|
|
</FieldGroup>
|
|
<div className="flex justify-end gap-2">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
onClick={onClose}
|
|
disabled={submitting}
|
|
>
|
|
{t('sidebar.addWorkspaceCancel')}
|
|
</Button>
|
|
<Button type="submit" disabled={submitting || !path.trim()}>
|
|
{submitting
|
|
? t('sidebar.addWorkspaceAdding')
|
|
: t('sidebar.addWorkspaceRegister')}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</DialogShell>
|
|
);
|
|
}
|