feat(FN-4606): complete Step 7 — docs and worktreesDir picker

Ref: Runfusion/Fusion#321
Fusion-Task-Id: FN-4606
Fusion-Task-Lineage: 94b23f37-944e-4bcd-bfa1-67d0a8c0a267
This commit is contained in:
Fusion
2026-05-15 08:48:00 -07:00
committed by gsxdsm
parent eacb823a37
commit edfd6e42ac
4 changed files with 115 additions and 9 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": minor
---
Add `worktreesDir` project setting to place task worktrees outside the project root. Supports absolute paths, paths relative to the project root, `~` expansion, and the `{repo}` token. Defaults to the existing `<projectRoot>/.worktrees` behavior when unset.

View File

@@ -228,6 +228,7 @@ Override precedence for direct merges is:
| `recycleWorktrees` | `boolean` | `false` | Reuse worktrees from a pool for faster startup. | | `recycleWorktrees` | `boolean` | `false` | Reuse worktrees from a pool for faster startup. |
| `executorAllowSiblingBranchRename` | `boolean` | `false` | Opt back into the legacy executor behavior that silently allocates sibling branches (`fusion/<task-id>-2`, `-2-2`, …) when the canonical task branch is already checked out elsewhere. When disabled (default), branch conflicts fail loudly, leave the task in `todo` with `status: "failed"`, and expose stranded commits for explicit recovery via [`fn task branch-recovery`](./cli-reference.md#fn-task). See [Task Management → Branch conflict recovery](./task-management.md#branch-conflict-recovery). The dashboard Settings modal exposes the same toggle with warning copy because this legacy mode is discouraged. | | `executorAllowSiblingBranchRename` | `boolean` | `false` | Opt back into the legacy executor behavior that silently allocates sibling branches (`fusion/<task-id>-2`, `-2-2`, …) when the canonical task branch is already checked out elsewhere. When disabled (default), branch conflicts fail loudly, leave the task in `todo` with `status: "failed"`, and expose stranded commits for explicit recovery via [`fn task branch-recovery`](./cli-reference.md#fn-task). See [Task Management → Branch conflict recovery](./task-management.md#branch-conflict-recovery). The dashboard Settings modal exposes the same toggle with warning copy because this legacy mode is discouraged. |
| `worktreeNaming` | `"random" \| "task-id" \| "task-title"` | `"random"` | Naming mode for new worktree directories. | | `worktreeNaming` | `"random" \| "task-id" \| "task-title"` | `"random"` | Naming mode for new worktree directories. |
| `worktreesDir` | `string` | `undefined` | Optional container directory for task worktrees. Supports absolute paths, project-relative paths, `~` expansion, and `{repo}` token substitution (project root basename). Defaults to `<projectRoot>/.worktrees` when unset and applies to newly-created worktrees/pool scans. |
| `taskPrefix` | `string` | `"FN"` | Prefix used for newly generated task IDs. | | `taskPrefix` | `string` | `"FN"` | Prefix used for newly generated task IDs. |
| `includeTaskIdInCommit` | `boolean` | `true` | Include task ID as commit scope in generated commits. | | `includeTaskIdInCommit` | `boolean` | `true` | Include task ID as commit scope in generated commits. |
| `commitAuthorEnabled` | `boolean` | `true` | Apply explicit `--author` attribution on Fusion commits. | | `commitAuthorEnabled` | `boolean` | `true` | Apply explicit `--author` attribution on Fusion commits. |

View File

@@ -199,6 +199,7 @@ API endpoints reviewed:
| `buildCommand` | Project | `GET/PUT /api/settings` | Project build command | | `buildCommand` | Project | `GET/PUT /api/settings` | Project build command |
| `recycleWorktrees` | Project | `GET/PUT /api/settings` | Worktree pool toggle | | `recycleWorktrees` | Project | `GET/PUT /api/settings` | Worktree pool toggle |
| `worktreeNaming` | Project | `GET/PUT /api/settings` | Worktree naming strategy | | `worktreeNaming` | Project | `GET/PUT /api/settings` | Worktree naming strategy |
| `worktreesDir` | Project | `GET/PUT /api/settings` | Optional worktree container directory (supports absolute/project-relative paths, `~`, `{repo}` token) |
| `taskPrefix` | Project | `GET/PUT /api/settings` | Task ID prefix | | `taskPrefix` | Project | `GET/PUT /api/settings` | Task ID prefix |
| `includeTaskIdInCommit` | Project | `GET/PUT /api/settings` | Commit scope formatting | | `includeTaskIdInCommit` | Project | `GET/PUT /api/settings` | Commit scope formatting |
| `defaultProviderOverride` | Project | `GET/PUT /api/settings` | Project-level override for base default provider | | `defaultProviderOverride` | Project | `GET/PUT /api/settings` | Project-level override for base default provider |

View File

@@ -457,6 +457,7 @@ export function SettingsModal({
const [prefixError, setPrefixError] = useState<string | null>(null); const [prefixError, setPrefixError] = useState<string | null>(null);
const [researchLimitError, setResearchLimitError] = useState<string | null>(null); const [researchLimitError, setResearchLimitError] = useState<string | null>(null);
const [overlapPathPickerIndex, setOverlapPathPickerIndex] = useState<number | null>(null); const [overlapPathPickerIndex, setOverlapPathPickerIndex] = useState<number | null>(null);
const [worktreesDirPickerOpen, setWorktreesDirPickerOpen] = useState(false);
const { const {
entries: overlapPathPickerEntries, entries: overlapPathPickerEntries,
@@ -467,6 +468,15 @@ export function SettingsModal({
refresh: refreshOverlapPathPicker, refresh: refreshOverlapPathPicker,
} = useWorkspaceFileBrowser("project", overlapPathPickerIndex !== null, projectId); } = useWorkspaceFileBrowser("project", overlapPathPickerIndex !== null, projectId);
const {
entries: worktreesDirPickerEntries,
currentPath: worktreesDirPickerCurrentPath,
setPath: setWorktreesDirPickerPath,
loading: worktreesDirPickerLoading,
error: worktreesDirPickerError,
refresh: refreshWorktreesDirPicker,
} = useWorkspaceFileBrowser("project", worktreesDirPickerOpen, projectId);
const { nodes } = useNodes(); const { nodes } = useNodes();
const experimentalFeatures = form.experimentalFeatures ?? {}; const experimentalFeatures = form.experimentalFeatures ?? {};
const remoteAccessEnabled = isExperimentalFeatureEnabled(experimentalFeatures, "remoteAccess"); const remoteAccessEnabled = isExperimentalFeatureEnabled(experimentalFeatures, "remoteAccess");
@@ -1607,6 +1617,35 @@ export function SettingsModal({
} }
}, [closeOverlapPathPicker]); }, [closeOverlapPathPicker]);
const openWorktreesDirPicker = useCallback(() => {
setWorktreesDirPickerPath(".");
setWorktreesDirPickerOpen(true);
}, [setWorktreesDirPickerPath]);
const closeWorktreesDirPicker = useCallback(() => {
setWorktreesDirPickerOpen(false);
}, []);
const selectWorktreesDirFromPicker = useCallback((path: string) => {
const normalizedPath = path.endsWith("/") ? path : `${path}/`;
setForm((f) => ({ ...f, worktreesDir: normalizedPath }));
closeWorktreesDirPicker();
}, [closeWorktreesDirPicker]);
const selectCurrentWorktreesDir = useCallback(() => {
const normalizedPath = worktreesDirPickerCurrentPath === "."
? "./"
: (worktreesDirPickerCurrentPath.endsWith("/") ? worktreesDirPickerCurrentPath : `${worktreesDirPickerCurrentPath}/`);
setForm((f) => ({ ...f, worktreesDir: normalizedPath }));
closeWorktreesDirPicker();
}, [worktreesDirPickerCurrentPath, closeWorktreesDirPicker]);
const handleWorktreesDirPickerOverlayClick = useCallback((event: MouseEvent<HTMLDivElement>) => {
if (event.target === event.currentTarget) {
closeWorktreesDirPicker();
}
}, [closeWorktreesDirPicker]);
const handleOverlapIgnorePathChange = useCallback((index: number, value: string) => { const handleOverlapIgnorePathChange = useCallback((index: number, value: string) => {
setForm((f) => { setForm((f) => {
const currentPaths = f.overlapIgnorePaths && f.overlapIgnorePaths.length > 0 const currentPaths = f.overlapIgnorePaths && f.overlapIgnorePaths.length > 0
@@ -3737,15 +3776,25 @@ export function SettingsModal({
</div> </div>
<div className="form-group"> <div className="form-group">
<label htmlFor="worktreesDir">Worktrees Directory</label> <label htmlFor="worktreesDir">Worktrees Directory</label>
<input <div className="settings-overlap-ignore-path-controls">
id="worktreesDir" <input
type="text" id="worktreesDir"
placeholder="~/.fn-worktrees/{repo} or .worktrees" type="text"
value={form.worktreesDir || ""} placeholder="~/.fn-worktrees/{repo} or .worktrees"
onChange={(e) => value={form.worktreesDir || ""}
setForm((f) => ({ ...f, worktreesDir: e.target.value })) onChange={(e) =>
} setForm((f) => ({ ...f, worktreesDir: e.target.value }))
/> }
/>
<button
type="button"
className="btn btn-sm"
onClick={openWorktreesDirPicker}
aria-label="Browse worktrees directory"
>
Browse
</button>
</div>
<small>Optional. Supports <code>~</code> and <code>{"{repo}"}</code>. Defaults to <code>&lt;projectRoot&gt;/.worktrees</code> when unset and only affects newly-created worktrees.</small> <small>Optional. Supports <code>~</code> and <code>{"{repo}"}</code>. Defaults to <code>&lt;projectRoot&gt;/.worktrees</code> when unset and only affects newly-created worktrees.</small>
</div> </div>
<div className="form-group"> <div className="form-group">
@@ -6595,6 +6644,56 @@ export function SettingsModal({
</div> </div>
</div> </div>
)} )}
{worktreesDirPickerOpen && (
<div
className="modal-overlay open"
onClick={handleWorktreesDirPickerOverlayClick}
role="dialog"
aria-modal="true"
aria-label="Browse worktrees directory"
>
<div className="modal modal-lg settings-overlap-path-picker-modal" onClick={(event) => event.stopPropagation()}>
<div className="modal-header">
<h3>Select worktrees directory</h3>
<button className="modal-close" onClick={closeWorktreesDirPicker} aria-label="Close">
&times;
</button>
</div>
<div className="modal-body settings-overlap-path-picker-body">
<p className="settings-overlap-path-picker-note">
Navigate to the folder where Fusion should create task worktrees, then select the current directory.
</p>
<FileBrowser
entries={worktreesDirPickerEntries}
currentPath={worktreesDirPickerCurrentPath}
onSelectFile={selectWorktreesDirFromPicker}
onNavigate={setWorktreesDirPickerPath}
loading={worktreesDirPickerLoading}
error={worktreesDirPickerError}
onRetry={refreshWorktreesDirPicker}
workspace="project"
projectId={projectId}
/>
</div>
<div className="modal-actions">
<div className="modal-actions-left">
<small>
Current directory: <code>{worktreesDirPickerCurrentPath === "." ? "(project root)" : worktreesDirPickerCurrentPath}</code>
</small>
</div>
<div className="modal-actions-right">
<button className="btn btn-sm" onClick={closeWorktreesDirPicker}>
Cancel
</button>
<button className="btn btn-primary btn-sm" onClick={selectCurrentWorktreesDir}>
Select current directory
</button>
</div>
</div>
</div>
</div>
)}
{/* Import Confirmation Dialog */} {/* Import Confirmation Dialog */}
{importDialogOpen && importPreview && ( {importDialogOpen && importPreview && (