mirror of
https://github.com/stablyai/orca
synced 2026-04-21 14:17:16 +00:00
feat: add non-git folder support (#356)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
37ae8ce525
commit
9b2b85c59d
32 changed files with 1009 additions and 138 deletions
365
docs/non-git-folder-support-plan.md
Normal file
365
docs/non-git-folder-support-plan.md
Normal file
|
|
@ -0,0 +1,365 @@
|
|||
# Non-Git Folder Support Plan
|
||||
|
||||
## Summary
|
||||
|
||||
Orca should support opening non-git folders in a limited "folder mode" so users can still use:
|
||||
|
||||
- terminal
|
||||
- file explorer
|
||||
- editor
|
||||
- search
|
||||
- quick open
|
||||
|
||||
Git-dependent features should remain unavailable in this mode:
|
||||
|
||||
- creating worktrees
|
||||
- removing git worktrees
|
||||
- source control
|
||||
- branch/base-ref workflows
|
||||
- pull request and checks integrations
|
||||
|
||||
The recommended implementation is to model a non-git folder as a repo with exactly one synthetic worktree representing the folder itself.
|
||||
|
||||
## Product Model
|
||||
|
||||
### Repo Types
|
||||
|
||||
Introduce two repo modes:
|
||||
|
||||
- `git`
|
||||
- `folder`
|
||||
|
||||
This can be stored directly on `Repo`, for example as `kind: 'git' | 'folder'`.
|
||||
|
||||
Why: Orca currently assumes every connected repo can enumerate git worktrees and branch metadata. Making repo type explicit lets the UI and IPC handlers suppress git-only functionality without scattered heuristics.
|
||||
|
||||
### Synthetic Worktree for Folder Mode
|
||||
|
||||
For a non-git folder, Orca should synthesize exactly one worktree-like entry:
|
||||
|
||||
- `path = repo.path`
|
||||
- `displayName = repo.displayName`
|
||||
- `isMainWorktree = true`
|
||||
- `branch = ''`
|
||||
- `head = ''`
|
||||
- `isBare = false`
|
||||
|
||||
The worktree ID can remain `${repo.id}::${repo.path}` to preserve the existing store shape.
|
||||
|
||||
This ID contract should be treated as stable for folder mode.
|
||||
|
||||
Why: much of the app is worktree-centric. Reusing the existing worktree abstraction is less invasive than teaching the editor, terminal, explorer, quick-open, and selection state to operate without any worktree at all.
|
||||
|
||||
## User Experience
|
||||
|
||||
### On Add / Load Attempt
|
||||
|
||||
When a selected folder is not a git repo, Orca should show a confirmation dialog instead of hard-failing:
|
||||
|
||||
- Title: `Open as Folder?`
|
||||
- Body: `This folder is not a Git repository. Orca can open it for editing, terminal, and search, but Git-based features like worktrees, source control, pull requests, and checks will be unavailable.`
|
||||
- Actions:
|
||||
- `Open Folder`
|
||||
- `Cancel`
|
||||
- optional: `Initialize Git Instead`
|
||||
|
||||
Why: users need to understand the capability downgrade before the folder is added to the workspace.
|
||||
|
||||
### Left Sidebar
|
||||
|
||||
A folder-mode repo should appear in the same left sidebar structure as existing repos, with one row underneath it:
|
||||
|
||||
- repo row
|
||||
- one synthetic worktree row representing the folder
|
||||
|
||||
Recommended indicator:
|
||||
|
||||
- repo badge or row badge: `Folder` or `Non-Git`
|
||||
|
||||
The synthetic row should use a folder-specific subtitle treatment instead of an empty branch slot.
|
||||
|
||||
Recommendation:
|
||||
|
||||
- primary label: folder display name
|
||||
- subtitle: `Folder` or a short path label
|
||||
|
||||
Do not style this as an error. It is a supported limited mode, not a broken state.
|
||||
|
||||
### Disabled / Hidden Git Features
|
||||
|
||||
Git-only features should either be hidden or disabled with an explanation.
|
||||
|
||||
Recommended behavior:
|
||||
|
||||
- `Create Worktree`: disabled or hidden for folder-mode repos
|
||||
- `Delete Worktree`: do not reuse worktree-delete semantics for folder-mode rows
|
||||
- Source Control tab: show inline empty state explaining git is required
|
||||
- Checks tab: show inline empty state explaining a git branch / PR context is required
|
||||
- branch/base-ref settings: hidden or disabled with explanation
|
||||
|
||||
Why: silent disappearance can feel broken. When users intentionally open a folder, the app should explain why a git surface is unavailable.
|
||||
|
||||
### Remove vs Delete Semantics
|
||||
|
||||
Folder mode must not reuse the current worktree deletion flow.
|
||||
|
||||
Recommendation:
|
||||
|
||||
- folder-mode row action: `Remove Folder from Orca`
|
||||
- git worktree row action: `Delete Worktree`
|
||||
|
||||
Why: for real git worktrees, the current delete flow can remove filesystem content as part of worktree cleanup. Reusing that path for a synthetic folder row would be unsafe because users may intend to disconnect the folder from Orca, not delete the folder tree from disk.
|
||||
|
||||
### Settings Page
|
||||
|
||||
Keep folder-mode entries in the existing `Repositories` settings list, but show a type label:
|
||||
|
||||
- `Git`
|
||||
- `Folder`
|
||||
|
||||
For folder-mode entries, keep generic settings:
|
||||
|
||||
- display name
|
||||
- badge color
|
||||
- remove from Orca
|
||||
|
||||
Hide or disable git-specific settings:
|
||||
|
||||
- default worktree base
|
||||
- base ref picker/search
|
||||
- branch-related settings
|
||||
- PR/check-related settings
|
||||
|
||||
Recommended note near the top of a folder-mode settings card:
|
||||
|
||||
`Opened as folder. Git features are unavailable for this workspace.`
|
||||
|
||||
Why: the Settings page is where users will verify what Orca thinks this connected root is. The settings surface must stay consistent with the sidebar and runtime behavior.
|
||||
|
||||
The settings view should also skip eager git-specific checks for folder-mode repos, including hook-related checks unless folder hooks are explicitly supported.
|
||||
|
||||
## Functional Scope
|
||||
|
||||
### Should Work in Folder Mode
|
||||
|
||||
- add folder to Orca
|
||||
- select the folder entry in the sidebar
|
||||
- open terminal in that folder
|
||||
- browse files
|
||||
- read and edit files
|
||||
- search files
|
||||
- quick open files
|
||||
- open external files within the authorized folder root
|
||||
- restore terminal/editor session state against the synthetic worktree across app restarts
|
||||
|
||||
### Should Not Work in Folder Mode
|
||||
|
||||
- create additional worktrees
|
||||
- remove git worktrees
|
||||
- branch naming flows
|
||||
- base branch selection
|
||||
- git status / diff / stage / unstage / discard
|
||||
- conflict and rebase state
|
||||
- PR linking based on branch identity
|
||||
- checks derived from PR head / branch
|
||||
- git polling / refresh loops for source-control state
|
||||
|
||||
## Implementation Outline
|
||||
|
||||
### 1. Add Repo Type
|
||||
|
||||
Update the shared repo type to distinguish git repos from folder-mode repos.
|
||||
|
||||
Potential shape:
|
||||
|
||||
```ts
|
||||
type Repo = {
|
||||
id: string
|
||||
path: string
|
||||
displayName: string
|
||||
badgeColor: string
|
||||
addedAt: number
|
||||
kind?: 'git' | 'folder'
|
||||
gitUsername?: string
|
||||
worktreeBaseRef?: string
|
||||
hookSettings?: RepoHookSettings
|
||||
}
|
||||
```
|
||||
|
||||
Why: existing persisted data may not have this field, so `git` should be treated as the default for backward compatibility.
|
||||
|
||||
### 2. Change Add-Repo Flow
|
||||
|
||||
Current behavior rejects non-git folders.
|
||||
|
||||
New behavior:
|
||||
|
||||
- detect whether selected path is git
|
||||
- if yes, add as `kind: 'git'`
|
||||
- if no, ask for confirmation and add as `kind: 'folder'` if accepted
|
||||
|
||||
This applies to:
|
||||
|
||||
- renderer add flow
|
||||
- main-process `repos:add`
|
||||
- runtime/CLI add flow if it should support folders too
|
||||
|
||||
### 3. Synthesize a Worktree for Folder Repos
|
||||
|
||||
Update worktree listing so folder-mode repos return a single synthetic worktree instead of `[]`.
|
||||
|
||||
This should apply to:
|
||||
|
||||
- `worktrees:list`
|
||||
- `worktrees:listAll`
|
||||
- any runtime-managed worktree listing APIs
|
||||
|
||||
Why: the app currently gates most of the workspace UI on `activeWorktreeId`. Returning no worktrees leaves the app stuck on the landing state even though the filesystem APIs could operate on the folder.
|
||||
|
||||
The synthetic worktree ID must be deterministic across restarts so session restore can reattach tabs, active selection, and terminal state correctly.
|
||||
|
||||
### 4. Suppress Git-Only Mutations
|
||||
|
||||
Guard git-only IPC and UI entry points for folder-mode repos:
|
||||
|
||||
- worktree creation
|
||||
- worktree removal
|
||||
- source control actions
|
||||
- base ref queries/search
|
||||
- branch-based PR/check flows where appropriate
|
||||
- git status polling / conflict polling / branch compare refresh loops
|
||||
|
||||
These guards should fail clearly with a user-facing explanation when reached.
|
||||
|
||||
This must cover all create-worktree entry points, not just one visible button:
|
||||
|
||||
- landing page CTA
|
||||
- keyboard shortcut
|
||||
- add-worktree dialog repo picker / submit path
|
||||
- any runtime or CLI create path that remains exposed
|
||||
|
||||
### 5. Update Sidebar and Settings Presentation
|
||||
|
||||
Add a neutral repo-type indicator in:
|
||||
|
||||
- left sidebar
|
||||
- settings repo cards
|
||||
|
||||
Ensure git-only controls are hidden or disabled for folder mode.
|
||||
|
||||
### 6. Handle Search / Quick Open Fallbacks
|
||||
|
||||
Quick open and text search currently fall back to git-based commands when `rg` is unavailable.
|
||||
|
||||
Folder-mode support needs one of these decisions:
|
||||
|
||||
1. Require `rg` for folder mode and surface a clear error when it is unavailable.
|
||||
2. Add non-git filesystem fallbacks for file listing and text search.
|
||||
|
||||
Recommendation: start with option 1 if we want a smaller implementation.
|
||||
|
||||
Why: the product value of folder mode is mainly unlocked on machines where `rg` exists. Non-git fallback walkers/searchers can be added later if needed.
|
||||
|
||||
If option 1 is chosen, the product should surface this clearly as a limitation rather than failing silently into empty quick-open or search results.
|
||||
|
||||
## Open Questions
|
||||
|
||||
### Hooks
|
||||
|
||||
Should `orca.yaml` hooks work for folder-mode repos?
|
||||
|
||||
Recommendation: do not include them in the initial scope unless there is a strong use case.
|
||||
|
||||
Reasoning: current hook behavior is designed around worktree creation/archive lifecycle, which folder mode does not have.
|
||||
|
||||
### CLI Semantics
|
||||
|
||||
Should the runtime/CLI also allow adding folder-mode repos, or should folder mode be UI-only at first?
|
||||
|
||||
Recommendation: keep CLI behavior aligned with the UI if feasible, but this can be phased.
|
||||
|
||||
If folder mode is UI-only initially, runtime and CLI commands should fail with an explicit folder-mode / unsupported message rather than the generic `Not a valid git repository`.
|
||||
|
||||
### Naming
|
||||
|
||||
Should the product call these `folders`, `workspaces`, or still `repositories`?
|
||||
|
||||
Recommendation: keep the top-level Settings section as `Repositories`, but label each entry as `Git` or `Folder`.
|
||||
|
||||
## Recommended Initial Scope
|
||||
|
||||
Ship the smallest coherent version:
|
||||
|
||||
- allow adding non-git folders
|
||||
- show one synthetic worktree row per folder
|
||||
- allow terminal, explorer, editor, search, and quick open
|
||||
- show a persistent `Folder` indicator
|
||||
- disable or hide git-only functionality
|
||||
- document that worktrees, source control, PRs, and checks are unavailable
|
||||
|
||||
This provides immediate utility without trying to redefine Orca's core worktree-oriented architecture.
|
||||
|
||||
## Risks and Gaps
|
||||
|
||||
### Unsafe Deletion Path
|
||||
|
||||
The current worktree delete path should not be reused for folder mode.
|
||||
|
||||
Why: deleting a synthetic folder row via worktree removal semantics could remove the real folder contents from disk instead of just disconnecting it from Orca.
|
||||
|
||||
### Incomplete Create-Worktree Suppression
|
||||
|
||||
Worktree creation must be blocked at every entry point.
|
||||
|
||||
Why: if only one button is hidden, users can still reach the flow through shortcuts, the landing page, dialogs, or runtime/CLI paths and hit confusing git-only failures.
|
||||
|
||||
### Background Git Polling
|
||||
|
||||
Folder mode must opt out of git polling loops.
|
||||
|
||||
Why: repeated git status/conflict polling against a non-git folder would create noisy logs, unnecessary subprocess churn, and avoidable UI work.
|
||||
|
||||
### Stable Synthetic Identity
|
||||
|
||||
Synthetic worktree IDs must remain deterministic.
|
||||
|
||||
Why: session restore keys open tabs, active selection, and terminal reattachment off worktree identity.
|
||||
|
||||
### Sidebar Presentation
|
||||
|
||||
Folder rows need intentional presentation rather than inheriting blank branch UI.
|
||||
|
||||
Why: a technically valid row with no branch text will look unfinished and make the mode feel accidental.
|
||||
|
||||
### Settings / Hooks Ambiguity
|
||||
|
||||
Folder-mode settings must not eagerly present or execute git-specific controls/checks.
|
||||
|
||||
Why: the settings surface is the canonical place where users verify the capabilities of a connected root.
|
||||
|
||||
### Runtime / CLI Divergence
|
||||
|
||||
Folder support needs an explicit cross-surface decision.
|
||||
|
||||
Why: allowing folders in the UI but rejecting them in runtime/CLI without a clear explanation will create inconsistent product behavior.
|
||||
|
||||
### `rg` as a Practical Requirement
|
||||
|
||||
Folder mode depends on `rg` unless non-git fallbacks are added for quick-open and search.
|
||||
|
||||
Why: current fallback implementations use `git ls-files` and `git grep`, which do not work for non-git folders.
|
||||
|
||||
## Test Checklist
|
||||
|
||||
- add-folder confirmation flow
|
||||
- persisted repo kind / backward compatibility
|
||||
- synthetic worktree listing for folder repos
|
||||
- deterministic synthetic worktree ID across restarts
|
||||
- folder-mode session restore
|
||||
- folder row uses `Remove Folder from Orca`, not worktree delete semantics
|
||||
- create-worktree entry points are all gated for folder repos
|
||||
- git polling is suppressed for folder repos
|
||||
- settings sections are gated by repo kind
|
||||
- folder sidebar row renders a non-branch subtitle
|
||||
- runtime/CLI behavior is explicit for folder repos
|
||||
- `rg`-missing behavior is covered for folder mode
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { realpath } from 'fs/promises'
|
||||
import { resolve, relative, dirname, basename, isAbsolute } from 'path'
|
||||
import type { Store } from '../persistence'
|
||||
import { listWorktrees } from '../git/worktree'
|
||||
import { listRepoWorktrees } from '../repo-worktrees'
|
||||
|
||||
export const PATH_ACCESS_DENIED_MESSAGE =
|
||||
'Access denied: path resolves outside allowed directories. If this blocks a legitimate workflow, please file a GitHub issue.'
|
||||
|
|
@ -68,7 +68,7 @@ export async function rebuildAuthorizedRootsCache(store: Store): Promise<void> {
|
|||
try {
|
||||
nextRoots.add(await normalizeExistingPath(repo.path))
|
||||
|
||||
const worktrees = await listWorktrees(repo.path)
|
||||
const worktrees = await listRepoWorktrees(repo)
|
||||
for (const worktree of worktrees) {
|
||||
nextRoots.add(await normalizeExistingPath(worktree.path))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { dialog, ipcMain } from 'electron'
|
|||
import { randomUUID } from 'crypto'
|
||||
import type { Store } from '../persistence'
|
||||
import type { Repo } from '../../shared/types'
|
||||
import { isFolderRepo } from '../../shared/repo-kind'
|
||||
import { REPO_COLORS } from '../../shared/constants'
|
||||
import { rebuildAuthorizedRootsCache } from './filesystem-auth'
|
||||
import {
|
||||
|
|
@ -29,8 +30,9 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
|||
return store.getRepos()
|
||||
})
|
||||
|
||||
ipcMain.handle('repos:add', async (_event, args: { path: string }) => {
|
||||
if (!isGitRepo(args.path)) {
|
||||
ipcMain.handle('repos:add', async (_event, args: { path: string; kind?: 'git' | 'folder' }) => {
|
||||
const repoKind = args.kind === 'folder' ? 'folder' : 'git'
|
||||
if (repoKind === 'git' && !isGitRepo(args.path)) {
|
||||
throw new Error(`Not a valid git repository: ${args.path}`)
|
||||
}
|
||||
|
||||
|
|
@ -45,7 +47,8 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
|||
path: args.path,
|
||||
displayName: getRepoName(args.path),
|
||||
badgeColor: REPO_COLORS[store.getRepos().length % REPO_COLORS.length],
|
||||
addedAt: Date.now()
|
||||
addedAt: Date.now(),
|
||||
kind: repoKind
|
||||
}
|
||||
|
||||
store.addRepo(repo)
|
||||
|
|
@ -67,7 +70,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
|||
args: {
|
||||
repoId: string
|
||||
updates: Partial<
|
||||
Pick<Repo, 'displayName' | 'badgeColor' | 'hookSettings' | 'worktreeBaseRef'>
|
||||
Pick<Repo, 'displayName' | 'badgeColor' | 'hookSettings' | 'worktreeBaseRef' | 'kind'>
|
||||
>
|
||||
}
|
||||
) => {
|
||||
|
|
@ -91,7 +94,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
|||
|
||||
ipcMain.handle('repos:getGitUsername', (_event, args: { repoId: string }) => {
|
||||
const repo = store.getRepo(args.repoId)
|
||||
if (!repo) {
|
||||
if (!repo || isFolderRepo(repo)) {
|
||||
return ''
|
||||
}
|
||||
return getGitUsername(repo.path)
|
||||
|
|
@ -99,7 +102,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
|||
|
||||
ipcMain.handle('repos:getBaseRefDefault', async (_event, args: { repoId: string }) => {
|
||||
const repo = store.getRepo(args.repoId)
|
||||
if (!repo) {
|
||||
if (!repo || isFolderRepo(repo)) {
|
||||
return 'origin/main'
|
||||
}
|
||||
return getBaseRefDefault(repo.path)
|
||||
|
|
@ -109,7 +112,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
|||
'repos:searchBaseRefs',
|
||||
async (_event, args: { repoId: string; query: string; limit?: number }) => {
|
||||
const repo = store.getRepo(args.repoId)
|
||||
if (!repo) {
|
||||
if (!repo || isFolderRepo(repo)) {
|
||||
return []
|
||||
}
|
||||
return searchBaseRefs(repo.path, args.query, args.limit ?? 25)
|
||||
|
|
|
|||
|
|
@ -111,7 +111,8 @@ export function shouldSetDisplayName(
|
|||
export function mergeWorktree(
|
||||
repoId: string,
|
||||
git: GitWorktreeInfo,
|
||||
meta: WorktreeMeta | undefined
|
||||
meta: WorktreeMeta | undefined,
|
||||
defaultDisplayName?: string
|
||||
): Worktree {
|
||||
const branchShort = git.branch.replace(/^refs\/heads\//, '')
|
||||
return {
|
||||
|
|
@ -122,7 +123,7 @@ export function mergeWorktree(
|
|||
branch: git.branch,
|
||||
isBare: git.isBare,
|
||||
isMainWorktree: git.isMainWorktree,
|
||||
displayName: meta?.displayName || branchShort || basename(git.path),
|
||||
displayName: meta?.displayName || branchShort || defaultDisplayName || basename(git.path),
|
||||
comment: meta?.comment || '',
|
||||
linkedIssue: meta?.linkedIssue ?? null,
|
||||
linkedPR: meta?.linkedPR ?? null,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
/* eslint-disable max-lines */
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
|
|
@ -206,6 +207,42 @@ describe('registerWorktreeHandlers', () => {
|
|||
expect(addWorktreeMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('lists a synthetic worktree for folder-mode repos', async () => {
|
||||
store.getRepos.mockReturnValue([
|
||||
{
|
||||
id: 'repo-1',
|
||||
path: '/workspace/folder',
|
||||
displayName: 'folder',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0,
|
||||
kind: 'folder'
|
||||
}
|
||||
])
|
||||
store.getRepo.mockReturnValue({
|
||||
id: 'repo-1',
|
||||
path: '/workspace/folder',
|
||||
displayName: 'folder',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0,
|
||||
kind: 'folder'
|
||||
})
|
||||
|
||||
const listed = await handlers['worktrees:list'](null, { repoId: 'repo-1' })
|
||||
|
||||
expect(listed).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'repo-1::/workspace/folder',
|
||||
repoId: 'repo-1',
|
||||
path: '/workspace/folder',
|
||||
displayName: 'folder',
|
||||
branch: '',
|
||||
head: '',
|
||||
isMainWorktree: true
|
||||
})
|
||||
])
|
||||
expect(listWorktreesMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects worktree creation when the branch name already belongs to a PR', async () => {
|
||||
getPRForBranchMock.mockResolvedValue({
|
||||
number: 3127,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { ipcMain } from 'electron'
|
|||
import { execFileSync } from 'child_process'
|
||||
import { rm } from 'fs/promises'
|
||||
import type { Store } from '../persistence'
|
||||
import { isFolderRepo } from '../../shared/repo-kind'
|
||||
import type {
|
||||
CreateWorktreeArgs,
|
||||
CreateWorktreeResult,
|
||||
|
|
@ -12,6 +13,7 @@ import type {
|
|||
import { getPRForBranch } from '../github/client'
|
||||
import { listWorktrees, addWorktree, removeWorktree } from '../git/worktree'
|
||||
import { getGitUsername, getDefaultBaseRef, getBranchConflictKind } from '../git/repo'
|
||||
import { listRepoWorktrees } from '../repo-worktrees'
|
||||
import {
|
||||
createSetupRunnerScript,
|
||||
getEffectiveHooks,
|
||||
|
|
@ -54,11 +56,11 @@ export function registerWorktreeHandlers(mainWindow: BrowserWindow, store: Store
|
|||
const allWorktrees: Worktree[] = []
|
||||
|
||||
for (const repo of repos) {
|
||||
const gitWorktrees = await listWorktrees(repo.path)
|
||||
const gitWorktrees = await listRepoWorktrees(repo)
|
||||
for (const gw of gitWorktrees) {
|
||||
const worktreeId = `${repo.id}::${gw.path}`
|
||||
const meta = store.getWorktreeMeta(worktreeId)
|
||||
allWorktrees.push(mergeWorktree(repo.id, gw, meta))
|
||||
allWorktrees.push(mergeWorktree(repo.id, gw, meta, repo.displayName))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -75,11 +77,11 @@ export function registerWorktreeHandlers(mainWindow: BrowserWindow, store: Store
|
|||
return []
|
||||
}
|
||||
|
||||
const gitWorktrees = await listWorktrees(repo.path)
|
||||
const gitWorktrees = await listRepoWorktrees(repo)
|
||||
return gitWorktrees.map((gw) => {
|
||||
const worktreeId = `${repo.id}::${gw.path}`
|
||||
const meta = store.getWorktreeMeta(worktreeId)
|
||||
return mergeWorktree(repo.id, gw, meta)
|
||||
return mergeWorktree(repo.id, gw, meta, repo.displayName)
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -90,6 +92,9 @@ export function registerWorktreeHandlers(mainWindow: BrowserWindow, store: Store
|
|||
if (!repo) {
|
||||
throw new Error(`Repo not found: ${args.repoId}`)
|
||||
}
|
||||
if (isFolderRepo(repo)) {
|
||||
throw new Error('Folder mode does not support creating worktrees.')
|
||||
}
|
||||
|
||||
const settings = store.getSettings()
|
||||
|
||||
|
|
@ -206,6 +211,9 @@ export function registerWorktreeHandlers(mainWindow: BrowserWindow, store: Store
|
|||
if (!repo) {
|
||||
throw new Error(`Repo not found: ${repoId}`)
|
||||
}
|
||||
if (isFolderRepo(repo)) {
|
||||
throw new Error('Folder mode does not support deleting worktrees.')
|
||||
}
|
||||
|
||||
// Run archive hook before removal
|
||||
const hooks = getEffectiveHooks(repo)
|
||||
|
|
@ -272,7 +280,7 @@ export function registerWorktreeHandlers(mainWindow: BrowserWindow, store: Store
|
|||
|
||||
ipcMain.handle('hooks:check', (_event, args: { repoId: string }) => {
|
||||
const repo = store.getRepo(args.repoId)
|
||||
if (!repo) {
|
||||
if (!repo || isFolderRepo(repo)) {
|
||||
return { hasHooks: false, hooks: null }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync, renameSync } from '
|
|||
import { join, dirname } from 'path'
|
||||
import { homedir } from 'os'
|
||||
import type { PersistedState, Repo, WorktreeMeta, GlobalSettings } from '../shared/types'
|
||||
import { isFolderRepo } from '../shared/repo-kind'
|
||||
import { getGitUsername } from './git/repo'
|
||||
import {
|
||||
getDefaultPersistedState,
|
||||
|
|
@ -146,7 +147,9 @@ export class Store {
|
|||
|
||||
updateRepo(
|
||||
id: string,
|
||||
updates: Partial<Pick<Repo, 'displayName' | 'badgeColor' | 'hookSettings' | 'worktreeBaseRef'>>
|
||||
updates: Partial<
|
||||
Pick<Repo, 'displayName' | 'badgeColor' | 'hookSettings' | 'worktreeBaseRef' | 'kind'>
|
||||
>
|
||||
): Repo | null {
|
||||
const repo = this.state.repos.find((r) => r.id === id)
|
||||
if (!repo) {
|
||||
|
|
@ -158,16 +161,18 @@ export class Store {
|
|||
}
|
||||
|
||||
private hydrateRepo(repo: Repo): Repo {
|
||||
const gitUsername =
|
||||
this.gitUsernameCache.get(repo.path) ??
|
||||
(() => {
|
||||
const username = getGitUsername(repo.path)
|
||||
this.gitUsernameCache.set(repo.path, username)
|
||||
return username
|
||||
})()
|
||||
const gitUsername = isFolderRepo(repo)
|
||||
? ''
|
||||
: (this.gitUsernameCache.get(repo.path) ??
|
||||
(() => {
|
||||
const username = getGitUsername(repo.path)
|
||||
this.gitUsernameCache.set(repo.path, username)
|
||||
return username
|
||||
})())
|
||||
|
||||
return {
|
||||
...repo,
|
||||
kind: isFolderRepo(repo) ? 'folder' : 'git',
|
||||
gitUsername,
|
||||
hookSettings: {
|
||||
...getDefaultRepoHookSettings(),
|
||||
|
|
|
|||
76
src/main/repo-worktrees.test.ts
Normal file
76
src/main/repo-worktrees.test.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { listWorktreesMock } = vi.hoisted(() => ({
|
||||
listWorktreesMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./git/worktree', () => ({
|
||||
listWorktrees: listWorktreesMock
|
||||
}))
|
||||
|
||||
import { createFolderWorktree, listRepoWorktrees } from './repo-worktrees'
|
||||
|
||||
describe('repo-worktrees', () => {
|
||||
beforeEach(() => {
|
||||
listWorktreesMock.mockReset()
|
||||
})
|
||||
|
||||
it('creates a stable synthetic worktree for folder repos', () => {
|
||||
expect(
|
||||
createFolderWorktree({
|
||||
id: 'repo-1',
|
||||
path: '/workspace/folder',
|
||||
displayName: 'folder',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0,
|
||||
kind: 'folder'
|
||||
})
|
||||
).toEqual({
|
||||
path: '/workspace/folder',
|
||||
head: '',
|
||||
branch: '',
|
||||
isBare: false,
|
||||
isMainWorktree: true
|
||||
})
|
||||
})
|
||||
|
||||
it('returns the synthetic folder worktree instead of shelling out to git', async () => {
|
||||
const result = await listRepoWorktrees({
|
||||
id: 'repo-1',
|
||||
path: '/workspace/folder',
|
||||
displayName: 'folder',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0,
|
||||
kind: 'folder'
|
||||
})
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
path: '/workspace/folder',
|
||||
head: '',
|
||||
branch: '',
|
||||
isBare: false,
|
||||
isMainWorktree: true
|
||||
}
|
||||
])
|
||||
expect(listWorktreesMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('delegates to git worktree listing for git repos', async () => {
|
||||
listWorktreesMock.mockResolvedValue([
|
||||
{ path: '/workspace/repo', head: 'abc', branch: '', isBare: false, isMainWorktree: true }
|
||||
])
|
||||
|
||||
const result = await listRepoWorktrees({
|
||||
id: 'repo-1',
|
||||
path: '/workspace/repo',
|
||||
displayName: 'repo',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0,
|
||||
kind: 'git'
|
||||
})
|
||||
|
||||
expect(listWorktreesMock).toHaveBeenCalledWith('/workspace/repo')
|
||||
expect(result).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
23
src/main/repo-worktrees.ts
Normal file
23
src/main/repo-worktrees.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import type { GitWorktreeInfo, Repo } from '../shared/types'
|
||||
import { listWorktrees } from './git/worktree'
|
||||
import { isFolderRepo } from '../shared/repo-kind'
|
||||
|
||||
export function createFolderWorktree(repo: Repo): GitWorktreeInfo {
|
||||
return {
|
||||
path: repo.path,
|
||||
head: '',
|
||||
branch: '',
|
||||
isBare: false,
|
||||
// Why: folder mode has no linked worktree graph. Treat the folder itself
|
||||
// as the single primary worktree so the rest of Orca's worktree-first UI
|
||||
// can keep using one stable workspace identity.
|
||||
isMainWorktree: true
|
||||
}
|
||||
}
|
||||
|
||||
export async function listRepoWorktrees(repo: Repo): Promise<GitWorktreeInfo[]> {
|
||||
if (isFolderRepo(repo)) {
|
||||
return [createFolderWorktree(repo)]
|
||||
}
|
||||
return await listWorktrees(repo.path)
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import { execFileSync } from 'child_process'
|
|||
import { randomUUID } from 'crypto'
|
||||
import { rm } from 'fs/promises'
|
||||
import type { CreateWorktreeResult, Repo } from '../../shared/types'
|
||||
import { isFolderRepo } from '../../shared/repo-kind'
|
||||
import type {
|
||||
RuntimeGraphStatus,
|
||||
RuntimeRepoSearchRefs,
|
||||
|
|
@ -22,13 +23,19 @@ import type {
|
|||
RuntimeSyncWindowGraph,
|
||||
RuntimeWorktreeListResult
|
||||
} from '../../shared/runtime-types'
|
||||
import { listWorktrees } from '../git/worktree'
|
||||
import { getPRForBranch } from '../github/client'
|
||||
import { getGitUsername, getDefaultBaseRef, getBranchConflictKind } from '../git/repo'
|
||||
import { addWorktree, removeWorktree } from '../git/worktree'
|
||||
import {
|
||||
getGitUsername,
|
||||
getDefaultBaseRef,
|
||||
getBranchConflictKind,
|
||||
isGitRepo,
|
||||
getRepoName,
|
||||
searchBaseRefs
|
||||
} from '../git/repo'
|
||||
import { listWorktrees, addWorktree, removeWorktree } from '../git/worktree'
|
||||
import { createSetupRunnerScript, getEffectiveHooks, runHook } from '../hooks'
|
||||
import { REPO_COLORS } from '../../shared/constants'
|
||||
import { isGitRepo, getRepoName, searchBaseRefs } from '../git/repo'
|
||||
import { listRepoWorktrees } from '../repo-worktrees'
|
||||
import type { Store } from '../persistence'
|
||||
import {
|
||||
computeBranchName,
|
||||
|
|
@ -456,11 +463,11 @@ export class OrcaRuntimeService {
|
|||
return this.store?.getRepos() ?? []
|
||||
}
|
||||
|
||||
async addRepo(path: string): Promise<Repo> {
|
||||
async addRepo(path: string, kind: 'git' | 'folder' = 'git'): Promise<Repo> {
|
||||
if (!this.store) {
|
||||
throw new Error('runtime_unavailable')
|
||||
}
|
||||
if (!isGitRepo(path)) {
|
||||
if (kind === 'git' && !isGitRepo(path)) {
|
||||
throw new Error(`Not a valid git repository: ${path}`)
|
||||
}
|
||||
|
||||
|
|
@ -474,7 +481,8 @@ export class OrcaRuntimeService {
|
|||
path,
|
||||
displayName: getRepoName(path),
|
||||
badgeColor: REPO_COLORS[this.store.getRepos().length % REPO_COLORS.length],
|
||||
addedAt: Date.now()
|
||||
addedAt: Date.now(),
|
||||
kind
|
||||
}
|
||||
this.store.addRepo(repo)
|
||||
this.invalidateResolvedWorktreeCache()
|
||||
|
|
@ -491,6 +499,9 @@ export class OrcaRuntimeService {
|
|||
throw new Error('runtime_unavailable')
|
||||
}
|
||||
const repo = await this.resolveRepoSelector(repoSelector)
|
||||
if (isFolderRepo(repo)) {
|
||||
throw new Error('Folder mode does not support base refs.')
|
||||
}
|
||||
const updated = this.store.updateRepo(repo.id, { worktreeBaseRef: baseRef })
|
||||
if (!updated) {
|
||||
throw new Error('repo_not_found')
|
||||
|
|
@ -509,6 +520,12 @@ export class OrcaRuntimeService {
|
|||
throw new Error('invalid_limit')
|
||||
}
|
||||
const repo = await this.resolveRepoSelector(repoSelector)
|
||||
if (isFolderRepo(repo)) {
|
||||
return {
|
||||
refs: [],
|
||||
truncated: false
|
||||
}
|
||||
}
|
||||
const refs = await searchBaseRefs(repo.path, query, limit + 1)
|
||||
return {
|
||||
refs: refs.slice(0, limit),
|
||||
|
|
@ -549,6 +566,9 @@ export class OrcaRuntimeService {
|
|||
}
|
||||
|
||||
const repo = await this.resolveRepoSelector(args.repoSelector)
|
||||
if (isFolderRepo(repo)) {
|
||||
throw new Error('Folder mode does not support creating worktrees.')
|
||||
}
|
||||
const settings = this.store.getSettings()
|
||||
const requestedName = args.name
|
||||
const sanitizedName = sanitizeWorktreeName(args.name)
|
||||
|
|
@ -678,6 +698,9 @@ export class OrcaRuntimeService {
|
|||
if (!repo) {
|
||||
throw new Error('repo_not_found')
|
||||
}
|
||||
if (isFolderRepo(repo)) {
|
||||
throw new Error('Folder mode does not support deleting worktrees.')
|
||||
}
|
||||
|
||||
const hooks = getEffectiveHooks(repo)
|
||||
if (hooks?.scripts.archive) {
|
||||
|
|
@ -868,10 +891,10 @@ export class OrcaRuntimeService {
|
|||
const metaById = this.store.getAllWorktreeMeta()
|
||||
const worktrees: ResolvedWorktree[] = []
|
||||
for (const repo of this.store.getRepos()) {
|
||||
const gitWorktrees = await listWorktrees(repo.path)
|
||||
const gitWorktrees = await listRepoWorktrees(repo)
|
||||
for (const gitWorktree of gitWorktrees) {
|
||||
const worktreeId = `${repo.id}::${gitWorktree.path}`
|
||||
const merged = mergeWorktree(repo.id, gitWorktree, metaById[worktreeId])
|
||||
const merged = mergeWorktree(repo.id, gitWorktree, metaById[worktreeId], repo.displayName)
|
||||
worktrees.push({
|
||||
id: merged.id,
|
||||
repoId: repo.id,
|
||||
|
|
|
|||
6
src/preload/index.d.ts
vendored
6
src/preload/index.d.ts
vendored
|
|
@ -27,11 +27,13 @@ import type { RuntimeStatus, RuntimeSyncWindowGraph } from '../../shared/runtime
|
|||
|
||||
type ReposApi = {
|
||||
list: () => Promise<Repo[]>
|
||||
add: (args: { path: string }) => Promise<Repo>
|
||||
add: (args: { path: string; kind?: 'git' | 'folder' }) => Promise<Repo>
|
||||
remove: (args: { repoId: string }) => Promise<void>
|
||||
update: (args: {
|
||||
repoId: string
|
||||
updates: Partial<Pick<Repo, 'displayName' | 'badgeColor' | 'hookSettings' | 'worktreeBaseRef'>>
|
||||
updates: Partial<
|
||||
Pick<Repo, 'displayName' | 'badgeColor' | 'hookSettings' | 'worktreeBaseRef' | 'kind'>
|
||||
>
|
||||
}) => Promise<Repo>
|
||||
pickFolder: () => Promise<string | null>
|
||||
getGitUsername: (args: { repoId: string }) => Promise<string>
|
||||
|
|
|
|||
|
|
@ -97,7 +97,8 @@ const api = {
|
|||
repos: {
|
||||
list: (): Promise<unknown[]> => ipcRenderer.invoke('repos:list'),
|
||||
|
||||
add: (args: { path: string }): Promise<unknown> => ipcRenderer.invoke('repos:add', args),
|
||||
add: (args: { path: string; kind?: 'git' | 'folder' }): Promise<unknown> =>
|
||||
ipcRenderer.invoke('repos:add', args),
|
||||
|
||||
remove: (args: { repoId: string }): Promise<void> => ipcRenderer.invoke('repos:remove', args),
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
/* eslint-disable max-lines */
|
||||
import { useEffect } from 'react'
|
||||
import { DEFAULT_WORKTREE_CARD_PROPERTIES } from '../../shared/constants'
|
||||
import { isGitRepoKind } from '../../shared/repo-kind'
|
||||
|
||||
import { Minimize2, PanelLeft, PanelRight } from 'lucide-react'
|
||||
import { TOGGLE_TERMINAL_PANE_EXPAND_EVENT } from '@/constants/terminal'
|
||||
|
|
@ -430,7 +431,7 @@ function App(): React.JSX.Element {
|
|||
|
||||
// Cmd/Ctrl+N — create worktree
|
||||
if (!e.altKey && !e.shiftKey && e.key.toLowerCase() === 'n') {
|
||||
if (repos.length === 0) {
|
||||
if (!repos.some((repo) => isGitRepoKind(repo))) {
|
||||
return
|
||||
}
|
||||
e.preventDefault()
|
||||
|
|
@ -468,7 +469,7 @@ function App(): React.JSX.Element {
|
|||
activeView,
|
||||
activeWorktreeId,
|
||||
openModal,
|
||||
repos.length,
|
||||
repos,
|
||||
toggleSidebar,
|
||||
toggleRightSidebar,
|
||||
setRightSidebarTab,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react'
|
|||
import { AlertTriangle, ExternalLink, FolderPlus, GitBranchPlus, Star } from 'lucide-react'
|
||||
import { cn } from '../lib/utils'
|
||||
import { useAppStore } from '../store'
|
||||
import { isGitRepoKind } from '../../../shared/repo-kind'
|
||||
import logo from '../../../../resources/logo.svg'
|
||||
|
||||
type ShortcutItem = {
|
||||
|
|
@ -119,7 +120,7 @@ export default function Landing(): React.JSX.Element {
|
|||
const addRepo = useAppStore((s) => s.addRepo)
|
||||
const openModal = useAppStore((s) => s.openModal)
|
||||
|
||||
const canCreateWorktree = repos.length > 0
|
||||
const canCreateWorktree = repos.some((repo) => isGitRepoKind(repo))
|
||||
|
||||
const [preflightIssues, setPreflightIssues] = useState<PreflightIssue[]>([])
|
||||
|
||||
|
|
@ -137,7 +138,8 @@ export default function Landing(): React.JSX.Element {
|
|||
issues.push({
|
||||
id: 'git',
|
||||
title: 'Git is not installed',
|
||||
description: 'Orca requires Git to manage repositories and worktrees.',
|
||||
description:
|
||||
'Git is required for Git repositories, source control, and worktree management.',
|
||||
fixLabel: 'Install Git',
|
||||
fixUrl: 'https://git-scm.com/downloads'
|
||||
})
|
||||
|
|
@ -212,7 +214,7 @@ export default function Landing(): React.JSX.Element {
|
|||
<button
|
||||
className="inline-flex items-center gap-1.5 bg-secondary/70 border border-border/80 text-foreground font-medium text-sm px-4 py-2 rounded-md transition-colors disabled:opacity-40 disabled:cursor-not-allowed enabled:cursor-pointer enabled:hover:bg-accent"
|
||||
disabled={!canCreateWorktree}
|
||||
title={!canCreateWorktree ? 'Add a repo first' : undefined}
|
||||
title={!canCreateWorktree ? 'Add a Git repo first' : undefined}
|
||||
onClick={() => openModal('create-worktree')}
|
||||
>
|
||||
<GitBranchPlus className="size-3.5" />
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|||
import { LoaderCircle, ExternalLink, RefreshCw, Check, X, Pencil } from 'lucide-react'
|
||||
import { useAppStore } from '@/store'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { isFolderRepo } from '../../../../shared/repo-kind'
|
||||
import PRActions from './PRActions'
|
||||
import {
|
||||
PullRequestIcon,
|
||||
|
|
@ -51,6 +52,7 @@ export default function ChecksPanel(): React.JSX.Element {
|
|||
}, [activeWorktreeId, worktreesByRepo, repos])
|
||||
|
||||
const branch = worktree ? worktree.branch.replace(/^refs\/heads\//, '') : ''
|
||||
const isFolder = repo ? isFolderRepo(repo) : false
|
||||
const prCacheKey = repo && branch ? `${repo.path}::${branch}` : ''
|
||||
const pr: PRInfo | null = prCacheKey ? (prCache[prCacheKey]?.data ?? null) : null
|
||||
const prNumber = pr?.number ?? null
|
||||
|
|
@ -60,13 +62,13 @@ export default function ChecksPanel(): React.JSX.Element {
|
|||
|
||||
// Fetch PR data when the active worktree/branch changes
|
||||
useEffect(() => {
|
||||
if (repo && branch) {
|
||||
if (repo && !isFolder && branch) {
|
||||
void fetchPRForBranch(repo.path, branch)
|
||||
}
|
||||
}, [repo, branch, fetchPRForBranch])
|
||||
}, [repo, isFolder, branch, fetchPRForBranch])
|
||||
|
||||
useEffect(() => {
|
||||
if (!repo || !branch || !pr || pr.mergeable !== 'CONFLICTING') {
|
||||
if (!repo || isFolder || !branch || !pr || pr.mergeable !== 'CONFLICTING') {
|
||||
conflictSummaryRefreshKeyRef.current = null
|
||||
return
|
||||
}
|
||||
|
|
@ -82,7 +84,7 @@ export default function ChecksPanel(): React.JSX.Element {
|
|||
// lists from an older payload.
|
||||
conflictSummaryRefreshKeyRef.current = refreshKey
|
||||
void fetchPRForBranch(repo.path, branch, { force: true })
|
||||
}, [repo, branch, pr, fetchPRForBranch])
|
||||
}, [repo, isFolder, branch, pr, fetchPRForBranch])
|
||||
|
||||
// Fetch checks via cached store method
|
||||
const fetchChecks = useCallback(
|
||||
|
|
@ -241,6 +243,16 @@ export default function ChecksPanel(): React.JSX.Element {
|
|||
</div>
|
||||
)
|
||||
}
|
||||
if (isFolder) {
|
||||
return (
|
||||
<div className="px-4 py-6">
|
||||
<div className="text-sm font-medium text-foreground">Checks unavailable</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
Checks require a Git branch and pull request context
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!pr) {
|
||||
// Why: during a rebase/merge/cherry-pick the worktree is on a detached
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import { useAppStore } from '@/store'
|
|||
import { detectLanguage } from '@/lib/language-detect'
|
||||
import { basename, dirname, joinPath } from '@/lib/path'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { isFolderRepo } from '../../../../shared/repo-kind'
|
||||
import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '@/components/ui/tooltip'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
|
|
@ -135,6 +136,7 @@ export default function SourceControl(): React.JSX.Element {
|
|||
() => repos.find((repo) => repo.id === activeWorktree?.repoId) ?? null,
|
||||
[activeWorktree?.repoId, repos]
|
||||
)
|
||||
const isFolder = activeRepo ? isFolderRepo(activeRepo) : false
|
||||
const worktreePath = activeWorktree?.path ?? null
|
||||
const entries = useMemo(
|
||||
() => (activeWorktreeId ? (gitStatusByWorktree[activeWorktreeId] ?? []) : []),
|
||||
|
|
@ -153,7 +155,7 @@ export default function SourceControl(): React.JSX.Element {
|
|||
const isBranchVisible = rightSidebarTab === 'source-control'
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeRepo) {
|
||||
if (!activeRepo || isFolder) {
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -174,7 +176,7 @@ export default function SourceControl(): React.JSX.Element {
|
|||
return () => {
|
||||
stale = true
|
||||
}
|
||||
}, [activeRepo])
|
||||
}, [activeRepo, isFolder])
|
||||
|
||||
const effectiveBaseRef = activeRepo?.worktreeBaseRef ?? defaultBaseRef
|
||||
const hasUncommittedEntries = entries.length > 0
|
||||
|
|
@ -185,7 +187,7 @@ export default function SourceControl(): React.JSX.Element {
|
|||
const prInfo: PRInfo | null = prCacheKey ? (prCache[prCacheKey]?.data ?? null) : null
|
||||
|
||||
useEffect(() => {
|
||||
if (!isBranchVisible || !activeRepo || !branchName || branchName === 'HEAD') {
|
||||
if (!isBranchVisible || !activeRepo || isFolder || !branchName || branchName === 'HEAD') {
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -194,7 +196,7 @@ export default function SourceControl(): React.JSX.Element {
|
|||
// to fetch that branch's PR immediately instead of waiting for the user to
|
||||
// reselect the worktree or open the separate Checks panel.
|
||||
void fetchPRForBranch(activeRepo.path, branchName)
|
||||
}, [activeRepo, branchName, fetchPRForBranch, isBranchVisible])
|
||||
}, [activeRepo, branchName, fetchPRForBranch, isBranchVisible, isFolder])
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const groups = {
|
||||
|
|
@ -225,7 +227,7 @@ export default function SourceControl(): React.JSX.Element {
|
|||
)
|
||||
|
||||
const refreshBranchCompare = useCallback(async () => {
|
||||
if (!activeWorktreeId || !worktreePath || !effectiveBaseRef) {
|
||||
if (!activeWorktreeId || !worktreePath || !effectiveBaseRef || isFolder) {
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -271,6 +273,7 @@ export default function SourceControl(): React.JSX.Element {
|
|||
beginGitBranchCompareRequest,
|
||||
branchName,
|
||||
effectiveBaseRef,
|
||||
isFolder,
|
||||
setGitBranchCompareResult,
|
||||
worktreePath
|
||||
])
|
||||
|
|
@ -279,7 +282,7 @@ export default function SourceControl(): React.JSX.Element {
|
|||
refreshBranchCompareRef.current = refreshBranchCompare
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeWorktreeId || !worktreePath || !isBranchVisible || !effectiveBaseRef) {
|
||||
if (!activeWorktreeId || !worktreePath || !isBranchVisible || !effectiveBaseRef || isFolder) {
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -289,7 +292,7 @@ export default function SourceControl(): React.JSX.Element {
|
|||
BRANCH_REFRESH_INTERVAL_MS
|
||||
)
|
||||
return () => window.clearInterval(intervalId)
|
||||
}, [activeWorktreeId, effectiveBaseRef, isBranchVisible, worktreePath])
|
||||
}, [activeWorktreeId, effectiveBaseRef, isBranchVisible, isFolder, worktreePath])
|
||||
|
||||
const toggleSection = useCallback((section: string) => {
|
||||
setCollapsedSections((prev) => {
|
||||
|
|
@ -415,6 +418,13 @@ export default function SourceControl(): React.JSX.Element {
|
|||
</div>
|
||||
)
|
||||
}
|
||||
if (isFolder) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full text-xs text-muted-foreground px-4 text-center">
|
||||
Source Control is only available for Git repositories
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const showGenericEmptyState =
|
||||
!hasUncommittedEntries && branchSummary?.status === 'ready' && branchEntries.length === 0
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import React from 'react'
|
||||
import React, { useMemo } from 'react'
|
||||
import { Files, Search, GitBranch, ListChecks } from 'lucide-react'
|
||||
import { useAppStore } from '@/store'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useSidebarResize } from '@/hooks/useSidebarResize'
|
||||
import type { RightSidebarTab, ActivityBarPosition } from '@/store/slices/editor'
|
||||
import type { CheckStatus } from '../../../../shared/types'
|
||||
import { isFolderRepo } from '../../../../shared/repo-kind'
|
||||
import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '@/components/ui/tooltip'
|
||||
import {
|
||||
ContextMenu,
|
||||
|
|
@ -71,6 +72,8 @@ type ActivityBarItem = {
|
|||
icon: React.ComponentType<{ size?: number; className?: string }>
|
||||
title: string
|
||||
shortcut: string
|
||||
/** When true, hidden for non-git (folder-mode) repos. */
|
||||
gitOnly?: boolean
|
||||
}
|
||||
|
||||
const isMac = navigator.userAgent.includes('Mac')
|
||||
|
|
@ -93,13 +96,15 @@ const ACTIVITY_ITEMS: ActivityBarItem[] = [
|
|||
id: 'source-control',
|
||||
icon: GitBranch,
|
||||
title: 'Source Control',
|
||||
shortcut: `${isMac ? '\u21E7' : 'Shift+'}${mod}G`
|
||||
shortcut: `${isMac ? '\u21E7' : 'Shift+'}${mod}G`,
|
||||
gitOnly: true
|
||||
},
|
||||
{
|
||||
id: 'checks',
|
||||
icon: ListChecks,
|
||||
title: 'Checks',
|
||||
shortcut: `${isMac ? '\u21E7' : 'Shift+'}${mod}K`
|
||||
shortcut: `${isMac ? '\u21E7' : 'Shift+'}${mod}K`,
|
||||
gitOnly: true
|
||||
}
|
||||
]
|
||||
|
||||
|
|
@ -114,6 +119,24 @@ export default function RightSidebar(): React.JSX.Element {
|
|||
const activityBarPosition = useAppStore((s) => s.activityBarPosition)
|
||||
const setActivityBarPosition = useAppStore((s) => s.setActivityBarPosition)
|
||||
|
||||
// Why: source control and checks are meaningless for non-git folders.
|
||||
// Hide those tabs so the activity bar only shows relevant actions.
|
||||
const activeRepo = useAppStore((s) => {
|
||||
const wt = findWorktreeById(s.worktreesByRepo, s.activeWorktreeId)
|
||||
return wt ? (s.repos.find((r) => r.id === wt.repoId) ?? null) : null
|
||||
})
|
||||
const isFolder = activeRepo ? isFolderRepo(activeRepo) : false
|
||||
const visibleItems = useMemo(
|
||||
() => (isFolder ? ACTIVITY_ITEMS.filter((item) => !item.gitOnly) : ACTIVITY_ITEMS),
|
||||
[isFolder]
|
||||
)
|
||||
|
||||
// If the active tab is hidden (e.g. switched from a git repo to a folder),
|
||||
// fall back to the first visible tab.
|
||||
const effectiveTab = visibleItems.some((item) => item.id === rightSidebarTab)
|
||||
? rightSidebarTab
|
||||
: visibleItems[0].id
|
||||
|
||||
const activityBarSideWidth = activityBarPosition === 'side' ? ACTIVITY_BAR_SIDE_WIDTH : 0
|
||||
const { containerRef, isResizing, onResizeStart } = useSidebarResize<HTMLDivElement>({
|
||||
isOpen: rightSidebarOpen,
|
||||
|
|
@ -127,18 +150,18 @@ export default function RightSidebar(): React.JSX.Element {
|
|||
|
||||
const panelContent = (
|
||||
<div className="flex flex-col flex-1 min-h-0 overflow-hidden scrollbar-sleek-parent">
|
||||
{rightSidebarTab === 'explorer' && <FileExplorer key={activeWorktreeId ?? 'none'} />}
|
||||
{rightSidebarTab === 'search' && <SearchPanel key={activeWorktreeId ?? 'none'} />}
|
||||
{rightSidebarTab === 'source-control' && <SourceControl key={activeWorktreeId ?? 'none'} />}
|
||||
{rightSidebarTab === 'checks' && <ChecksPanel key={activeWorktreeId ?? 'none'} />}
|
||||
{effectiveTab === 'explorer' && <FileExplorer key={activeWorktreeId ?? 'none'} />}
|
||||
{effectiveTab === 'search' && <SearchPanel key={activeWorktreeId ?? 'none'} />}
|
||||
{effectiveTab === 'source-control' && <SourceControl key={activeWorktreeId ?? 'none'} />}
|
||||
{effectiveTab === 'checks' && <ChecksPanel key={activeWorktreeId ?? 'none'} />}
|
||||
</div>
|
||||
)
|
||||
|
||||
const activityBarIcons = ACTIVITY_ITEMS.map((item) => (
|
||||
const activityBarIcons = visibleItems.map((item) => (
|
||||
<ActivityBarButton
|
||||
key={item.id}
|
||||
item={item}
|
||||
active={rightSidebarTab === item.id}
|
||||
active={effectiveTab === item.id}
|
||||
onClick={() => setRightSidebarTab(item.id)}
|
||||
layout={activityBarPosition}
|
||||
statusIndicator={item.id === 'checks' ? checksStatus : null}
|
||||
|
|
@ -177,7 +200,7 @@ export default function RightSidebar(): React.JSX.Element {
|
|||
/* ── Side layout: static title header ── */
|
||||
<div className="flex items-center h-[33px] min-h-[33px] px-3 border-b border-border">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-foreground">
|
||||
{ACTIVITY_ITEMS.find((item) => item.id === rightSidebarTab)?.title ?? ''}
|
||||
{visibleItems.find((item) => item.id === effectiveTab)?.title ?? ''}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useCallback, useEffect, useMemo } from 'react'
|
||||
import { useAppStore } from '@/store'
|
||||
import type { GitConflictOperation, GitStatusResult } from '../../../../shared/types'
|
||||
import { isGitRepoKind } from '../../../../shared/repo-kind'
|
||||
|
||||
const POLL_INTERVAL_MS = 3000
|
||||
|
||||
|
|
@ -36,6 +37,8 @@ export function useGitStatusPolling(): void {
|
|||
}
|
||||
return null
|
||||
}, [activeWorktreeId, worktreesByRepo])
|
||||
const activeRepo = useAppStore((s) => s.repos.find((repo) => repo.id === activeRepoId) ?? null)
|
||||
const activeRepoSupportsGit = activeRepo ? isGitRepoKind(activeRepo) : false
|
||||
|
||||
// Why: build a list of non-active worktrees that still have a known conflict
|
||||
// operation (merge/rebase/cherry-pick). These need lightweight polling so
|
||||
|
|
@ -50,6 +53,10 @@ export function useGitStatusPolling(): void {
|
|||
for (const worktrees of Object.values(worktreesByRepo)) {
|
||||
const wt = worktrees.find((w) => w.id === worktreeId)
|
||||
if (wt) {
|
||||
const repo = useAppStore.getState().repos.find((entry) => entry.id === wt.repoId)
|
||||
if (repo && !isGitRepoKind(repo)) {
|
||||
break
|
||||
}
|
||||
result.push({ id: wt.id, path: wt.path })
|
||||
break
|
||||
}
|
||||
|
|
@ -59,7 +66,7 @@ export function useGitStatusPolling(): void {
|
|||
}, [conflictOperationByWorktree, activeWorktreeId, worktreesByRepo])
|
||||
|
||||
const fetchStatus = useCallback(async () => {
|
||||
if (!activeWorktreeId || !worktreePath) {
|
||||
if (!activeWorktreeId || !worktreePath || !activeRepoSupportsGit) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
|
|
@ -68,7 +75,7 @@ export function useGitStatusPolling(): void {
|
|||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [activeWorktreeId, worktreePath, setGitStatus])
|
||||
}, [activeRepoSupportsGit, activeWorktreeId, worktreePath, setGitStatus])
|
||||
|
||||
useEffect(() => {
|
||||
void fetchStatus()
|
||||
|
|
@ -77,7 +84,7 @@ export function useGitStatusPolling(): void {
|
|||
}, [fetchStatus])
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeRepoId) {
|
||||
if (!activeRepoId || !activeRepoSupportsGit) {
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -88,7 +95,7 @@ export function useGitStatusPolling(): void {
|
|||
void fetchWorktrees(activeRepoId)
|
||||
const intervalId = setInterval(() => void fetchWorktrees(activeRepoId), POLL_INTERVAL_MS)
|
||||
return () => clearInterval(intervalId)
|
||||
}, [activeRepoId, fetchWorktrees])
|
||||
}, [activeRepoId, activeRepoSupportsGit, fetchWorktrees])
|
||||
|
||||
// Why: poll conflict operation for non-active worktrees that have a stale
|
||||
// non-unknown operation. This is a lightweight fs-only check (no git status)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useState } from 'react'
|
||||
import type { OrcaHooks, Repo, RepoHookSettings, SetupRunPolicy } from '../../../../shared/types'
|
||||
import { getRepoKindLabel, isFolderRepo } from '../../../../shared/repo-kind'
|
||||
import { REPO_COLORS } from '../../../../shared/constants'
|
||||
import { Button } from '../ui/button'
|
||||
import { Input } from '../ui/input'
|
||||
|
|
@ -22,6 +23,7 @@ type RepositoryPaneProps = {
|
|||
}
|
||||
|
||||
export function getRepositoryPaneSearchEntries(repo: Repo): SettingsSearchEntry[] {
|
||||
const isFolder = isFolderRepo(repo)
|
||||
return [
|
||||
{
|
||||
title: 'Display Name',
|
||||
|
|
@ -33,31 +35,45 @@ export function getRepositoryPaneSearchEntries(repo: Repo): SettingsSearchEntry[
|
|||
description: 'Repo color used in the sidebar and tabs.',
|
||||
keywords: [repo.displayName, 'color', 'badge']
|
||||
},
|
||||
{
|
||||
title: 'Default Worktree Base',
|
||||
description: 'Default base branch or ref when creating worktrees.',
|
||||
keywords: [repo.displayName, 'base ref', 'branch']
|
||||
},
|
||||
...(isFolder
|
||||
? []
|
||||
: [
|
||||
{
|
||||
title: 'Default Worktree Base',
|
||||
description: 'Default base branch or ref when creating worktrees.',
|
||||
keywords: [repo.displayName, 'base ref', 'branch']
|
||||
}
|
||||
]),
|
||||
{
|
||||
title: 'Remove Repo',
|
||||
description: 'Remove this repository from Orca.',
|
||||
keywords: [repo.displayName, 'delete', 'repository']
|
||||
},
|
||||
{
|
||||
title: 'orca.yaml hooks',
|
||||
description: 'Shared setup and archive hook commands for this repository.',
|
||||
keywords: [repo.displayName, 'hooks', 'setup', 'archive', 'yaml']
|
||||
},
|
||||
{
|
||||
title: 'Legacy Repo-Local Hooks',
|
||||
description: 'Older setup and archive hook scripts stored in local repo settings.',
|
||||
keywords: [repo.displayName, 'legacy', 'fallback', 'hooks']
|
||||
},
|
||||
{
|
||||
title: 'When to Run Setup',
|
||||
description: 'Choose the default behavior when a setup command is available.',
|
||||
keywords: [repo.displayName, 'setup run policy', 'ask', 'run by default', 'skip by default']
|
||||
}
|
||||
...(isFolder
|
||||
? []
|
||||
: [
|
||||
{
|
||||
title: 'orca.yaml hooks',
|
||||
description: 'Shared setup and archive hook commands for this repository.',
|
||||
keywords: [repo.displayName, 'hooks', 'setup', 'archive', 'yaml']
|
||||
},
|
||||
{
|
||||
title: 'Legacy Repo-Local Hooks',
|
||||
description: 'Older setup and archive hook scripts stored in local repo settings.',
|
||||
keywords: [repo.displayName, 'legacy', 'fallback', 'hooks']
|
||||
},
|
||||
{
|
||||
title: 'When to Run Setup',
|
||||
description: 'Choose the default behavior when a setup command is available.',
|
||||
keywords: [
|
||||
repo.displayName,
|
||||
'setup run policy',
|
||||
'ask',
|
||||
'run by default',
|
||||
'skip by default'
|
||||
]
|
||||
}
|
||||
])
|
||||
]
|
||||
}
|
||||
|
||||
|
|
@ -68,6 +84,7 @@ export function RepositoryPane({
|
|||
updateRepo,
|
||||
removeRepo
|
||||
}: RepositoryPaneProps): React.JSX.Element {
|
||||
const isFolder = isFolderRepo(repo)
|
||||
const searchQuery = useAppStore((state) => state.settingsSearchQuery)
|
||||
const [confirmingRemove, setConfirmingRemove] = useState<string | null>(null)
|
||||
const [copiedTemplate, setCopiedTemplate] = useState(false)
|
||||
|
|
@ -128,8 +145,12 @@ export function RepositoryPane({
|
|||
}
|
||||
|
||||
const allEntries = getRepositoryPaneSearchEntries(repo)
|
||||
const identityEntries = allEntries.slice(0, 4)
|
||||
const hooksEntries = allEntries.slice(4)
|
||||
const identityEntries = allEntries.filter((entry) =>
|
||||
['Display Name', 'Badge Color', 'Default Worktree Base', 'Remove Repo'].includes(entry.title)
|
||||
)
|
||||
const hooksEntries = allEntries.filter((entry) =>
|
||||
['orca.yaml hooks', 'Legacy Repo-Local Hooks', 'When to Run Setup'].includes(entry.title)
|
||||
)
|
||||
|
||||
const visibleSections = [
|
||||
matchesSettingsSearch(searchQuery, identityEntries) ? (
|
||||
|
|
@ -140,8 +161,15 @@ export function RepositoryPane({
|
|||
<p className="text-xs text-muted-foreground">
|
||||
Repo-specific display details for the sidebar and tabs.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Type: <span className="text-foreground">{getRepoKindLabel(repo)}</span>
|
||||
</p>
|
||||
{isFolder ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Opened as folder. Git features are unavailable for this workspace.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<SearchableSetting
|
||||
title="Remove Repo"
|
||||
description="Remove this repository from Orca."
|
||||
|
|
@ -202,23 +230,25 @@ export function RepositoryPane({
|
|||
</div>
|
||||
</SearchableSetting>
|
||||
|
||||
<SearchableSetting
|
||||
title="Default Worktree Base"
|
||||
description="Default base branch or ref when creating worktrees."
|
||||
keywords={[repo.displayName, 'base ref', 'branch']}
|
||||
className="space-y-3"
|
||||
>
|
||||
<Label>Default Worktree Base</Label>
|
||||
<BaseRefPicker
|
||||
repoId={repo.id}
|
||||
currentBaseRef={repo.worktreeBaseRef}
|
||||
onSelect={(ref) => updateRepo(repo.id, { worktreeBaseRef: ref })}
|
||||
onUsePrimary={() => updateRepo(repo.id, { worktreeBaseRef: undefined })}
|
||||
/>
|
||||
</SearchableSetting>
|
||||
{!isFolder ? (
|
||||
<SearchableSetting
|
||||
title="Default Worktree Base"
|
||||
description="Default base branch or ref when creating worktrees."
|
||||
keywords={[repo.displayName, 'base ref', 'branch']}
|
||||
className="space-y-3"
|
||||
>
|
||||
<Label>Default Worktree Base</Label>
|
||||
<BaseRefPicker
|
||||
repoId={repo.id}
|
||||
currentBaseRef={repo.worktreeBaseRef}
|
||||
onSelect={(ref) => updateRepo(repo.id, { worktreeBaseRef: ref })}
|
||||
onUsePrimary={() => updateRepo(repo.id, { worktreeBaseRef: undefined })}
|
||||
/>
|
||||
</SearchableSetting>
|
||||
) : null}
|
||||
</section>
|
||||
) : null,
|
||||
matchesSettingsSearch(searchQuery, hooksEntries) ? (
|
||||
!isFolder && matchesSettingsSearch(searchQuery, hooksEntries) ? (
|
||||
<RepositoryHooksSection
|
||||
key="hooks"
|
||||
repo={repo}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Keyboard, Palette, SlidersHorizontal, SquareTerminal } from 'lucide-react'
|
||||
import type { OrcaHooks } from '../../../../shared/types'
|
||||
import { getRepoKindLabel, isFolderRepo } from '../../../../shared/repo-kind'
|
||||
import { useAppStore } from '../../store'
|
||||
import { getSystemPrefersDark } from '@/lib/terminal-theme'
|
||||
import { SCROLLBACK_PRESETS_MB, getFallbackTerminalFonts } from './SettingsConstants'
|
||||
|
|
@ -144,6 +145,9 @@ function Settings(): React.JSX.Element {
|
|||
const checkHooks = async (): Promise<void> => {
|
||||
const results = await Promise.all(
|
||||
repos.map(async (repo) => {
|
||||
if (isFolderRepo(repo)) {
|
||||
return [repo.id, { hasHooks: false, hooks: null }] as const
|
||||
}
|
||||
try {
|
||||
const result = await window.api.hooks.check({ repoId: repo.id })
|
||||
return [repo.id, result] as const
|
||||
|
|
@ -220,7 +224,7 @@ function Settings(): React.JSX.Element {
|
|||
...repos.map((repo) => ({
|
||||
id: `repo-${repo.id}`,
|
||||
title: repo.displayName,
|
||||
description: repo.path,
|
||||
description: `${getRepoKindLabel(repo)} • ${repo.path}`,
|
||||
icon: SlidersHorizontal,
|
||||
searchEntries: getRepositoryPaneSearchEntries(repo)
|
||||
}))
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import RepoDotLabel from '@/components/repo/RepoDotLabel'
|
|||
import { parseGitHubIssueOrPRNumber } from '@/lib/github-links'
|
||||
import { SPACE_NAMES } from '@/constants/space-names'
|
||||
import { ensureWorktreeHasInitialTerminal } from '@/lib/worktree-activation'
|
||||
import { isGitRepoKind } from '../../../../shared/repo-kind'
|
||||
|
||||
const DIALOG_CLOSE_RESET_DELAY_MS = 200
|
||||
|
||||
|
|
@ -52,6 +53,7 @@ const AddWorktreeDialog = React.memo(function AddWorktreeDialog() {
|
|||
const setRightSidebarTab = useAppStore((s) => s.setRightSidebarTab)
|
||||
const worktreesByRepo = useAppStore((s) => s.worktreesByRepo)
|
||||
const settings = useAppStore((s) => s.settings)
|
||||
const eligibleRepos = useMemo(() => repos.filter((repo) => isGitRepoKind(repo)), [repos])
|
||||
|
||||
const [repoId, setRepoId] = useState<string>('')
|
||||
const [name, setName] = useState('')
|
||||
|
|
@ -75,7 +77,7 @@ const AddWorktreeDialog = React.memo(function AddWorktreeDialog() {
|
|||
() => findRepoIdForWorktree(activeWorktreeId, worktreesByRepo),
|
||||
[activeWorktreeId, worktreesByRepo]
|
||||
)
|
||||
const selectedRepo = repos.find((r) => r.id === repoId)
|
||||
const selectedRepo = eligibleRepos.find((r) => r.id === repoId)
|
||||
const setupConfig = useMemo(
|
||||
() => getSetupConfig(selectedRepo, yamlHooks),
|
||||
[selectedRepo, yamlHooks]
|
||||
|
|
@ -103,15 +105,18 @@ const AddWorktreeDialog = React.memo(function AddWorktreeDialog() {
|
|||
const shouldWaitForSetupCheck = Boolean(selectedRepo) && isSetupCheckPending
|
||||
|
||||
// Auto-select repo when dialog opens (adjusting state during render)
|
||||
if (isOpen && !prevIsOpenRef.current && repos.length > 0) {
|
||||
if (preselectedRepoId && repos.some((repo) => repo.id === preselectedRepoId)) {
|
||||
if (isOpen && !prevIsOpenRef.current && eligibleRepos.length > 0) {
|
||||
if (preselectedRepoId && eligibleRepos.some((repo) => repo.id === preselectedRepoId)) {
|
||||
setRepoId(preselectedRepoId)
|
||||
} else if (activeWorktreeRepoId && repos.some((repo) => repo.id === activeWorktreeRepoId)) {
|
||||
} else if (
|
||||
activeWorktreeRepoId &&
|
||||
eligibleRepos.some((repo) => repo.id === activeWorktreeRepoId)
|
||||
) {
|
||||
setRepoId(activeWorktreeRepoId)
|
||||
} else if (activeRepoId && repos.some((repo) => repo.id === activeRepoId)) {
|
||||
} else if (activeRepoId && eligibleRepos.some((repo) => repo.id === activeRepoId)) {
|
||||
setRepoId(activeRepoId)
|
||||
} else {
|
||||
setRepoId(repos[0].id)
|
||||
setRepoId(eligibleRepos[0].id)
|
||||
}
|
||||
}
|
||||
prevIsOpenRef.current = isOpen
|
||||
|
|
@ -139,7 +144,7 @@ const AddWorktreeDialog = React.memo(function AddWorktreeDialog() {
|
|||
)
|
||||
|
||||
const handleCreate = useCallback(async () => {
|
||||
if (!repoId || !name.trim() || shouldWaitForSetupCheck) {
|
||||
if (!repoId || !name.trim() || shouldWaitForSetupCheck || !selectedRepo) {
|
||||
return
|
||||
}
|
||||
setCreateError(null)
|
||||
|
|
@ -217,6 +222,7 @@ const AddWorktreeDialog = React.memo(function AddWorktreeDialog() {
|
|||
settings?.rightSidebarOpenByDefault,
|
||||
handleOpenChange,
|
||||
resolvedSetupDecision,
|
||||
selectedRepo,
|
||||
setupConfig,
|
||||
shouldWaitForSetupCheck
|
||||
])
|
||||
|
|
@ -309,7 +315,7 @@ const AddWorktreeDialog = React.memo(function AddWorktreeDialog() {
|
|||
if (isOpen && repos.length === 0) {
|
||||
handleOpenChange(false)
|
||||
}
|
||||
}, [isOpen, repos.length, handleOpenChange])
|
||||
}, [eligibleRepos.length, handleOpenChange, isOpen, repos.length])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isOpen || !repoId) {
|
||||
|
|
@ -405,7 +411,7 @@ const AddWorktreeDialog = React.memo(function AddWorktreeDialog() {
|
|||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{repos.map((r) => (
|
||||
{eligibleRepos.map((r) => (
|
||||
<SelectItem key={r.id} value={r.id}>
|
||||
<RepoDotLabel name={r.displayName} color={r.badgeColor} />
|
||||
</SelectItem>
|
||||
|
|
@ -570,6 +576,7 @@ const AddWorktreeDialog = React.memo(function AddWorktreeDialog() {
|
|||
!name.trim() ||
|
||||
creating ||
|
||||
shouldWaitForSetupCheck ||
|
||||
!selectedRepo ||
|
||||
(requiresExplicitSetupChoice && !setupDecision)
|
||||
}
|
||||
className="text-xs"
|
||||
|
|
|
|||
66
src/renderer/src/components/sidebar/NonGitFolderDialog.tsx
Normal file
66
src/renderer/src/components/sidebar/NonGitFolderDialog.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import React, { useCallback } from 'react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useAppStore } from '@/store'
|
||||
|
||||
const NonGitFolderDialog = React.memo(function NonGitFolderDialog() {
|
||||
const activeModal = useAppStore((s) => s.activeModal)
|
||||
const modalData = useAppStore((s) => s.modalData)
|
||||
const closeModal = useAppStore((s) => s.closeModal)
|
||||
const addNonGitFolder = useAppStore((s) => s.addNonGitFolder)
|
||||
|
||||
const isOpen = activeModal === 'confirm-non-git-folder'
|
||||
const folderPath = typeof modalData.folderPath === 'string' ? modalData.folderPath : ''
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
if (folderPath) {
|
||||
void addNonGitFolder(folderPath)
|
||||
}
|
||||
closeModal()
|
||||
}, [addNonGitFolder, closeModal, folderPath])
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
if (!open) {
|
||||
closeModal()
|
||||
}
|
||||
},
|
||||
[closeModal]
|
||||
)
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="max-w-sm sm:max-w-sm" showCloseButton={false}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-sm">Open as Folder</DialogTitle>
|
||||
<DialogDescription className="text-xs">
|
||||
This folder isn't a Git repository. You'll have the editor, terminal, and
|
||||
search, but Git-based features won't be available.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{folderPath && (
|
||||
<div className="rounded-md border border-border/70 bg-muted/35 px-3 py-2 text-xs">
|
||||
<div className="break-all text-muted-foreground">{folderPath}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => handleOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleConfirm}>Open as Folder</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
})
|
||||
|
||||
export default NonGitFolderDialog
|
||||
62
src/renderer/src/components/sidebar/RemoveFolderDialog.tsx
Normal file
62
src/renderer/src/components/sidebar/RemoveFolderDialog.tsx
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import React, { useCallback } from 'react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useAppStore } from '@/store'
|
||||
|
||||
const RemoveFolderDialog = React.memo(function RemoveFolderDialog() {
|
||||
const activeModal = useAppStore((s) => s.activeModal)
|
||||
const modalData = useAppStore((s) => s.modalData)
|
||||
const closeModal = useAppStore((s) => s.closeModal)
|
||||
const removeRepo = useAppStore((s) => s.removeRepo)
|
||||
|
||||
const isOpen = activeModal === 'confirm-remove-folder'
|
||||
const repoId = typeof modalData.repoId === 'string' ? modalData.repoId : ''
|
||||
const displayName = typeof modalData.displayName === 'string' ? modalData.displayName : ''
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
if (repoId) {
|
||||
void removeRepo(repoId)
|
||||
}
|
||||
closeModal()
|
||||
}, [closeModal, removeRepo, repoId])
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
if (!open) {
|
||||
closeModal()
|
||||
}
|
||||
},
|
||||
[closeModal]
|
||||
)
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="max-w-sm sm:max-w-sm" showCloseButton={false}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-sm">Remove Folder</DialogTitle>
|
||||
<DialogDescription className="text-xs">
|
||||
Remove <span className="break-all font-medium text-foreground">{displayName}</span> from
|
||||
Orca? The folder will not be deleted from disk.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => handleOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleConfirm}>
|
||||
Remove
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
})
|
||||
|
||||
export default RemoveFolderDialog
|
||||
|
|
@ -3,6 +3,7 @@ import { Plus, SlidersHorizontal } from 'lucide-react'
|
|||
import { useAppStore } from '@/store'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
|
||||
import { isGitRepoKind } from '../../../../shared/repo-kind'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
|
|
@ -36,7 +37,7 @@ const newWorktreeShortcutLabel = isMac ? '⌘N' : 'Ctrl+N'
|
|||
const SidebarHeader = React.memo(function SidebarHeader() {
|
||||
const openModal = useAppStore((s) => s.openModal)
|
||||
const repos = useAppStore((s) => s.repos)
|
||||
const canCreateWorktree = repos.length > 0
|
||||
const canCreateWorktree = repos.some((repo) => isGitRepoKind(repo))
|
||||
|
||||
const worktreeCardProperties = useAppStore((s) => s.worktreeCardProperties)
|
||||
const toggleWorktreeCardProperty = useAppStore((s) => s.toggleWorktreeCardProperty)
|
||||
|
|
@ -121,7 +122,7 @@ const SidebarHeader = React.memo(function SidebarHeader() {
|
|||
<TooltipContent side="right" sideOffset={6}>
|
||||
{canCreateWorktree
|
||||
? `New worktree (${newWorktreeShortcutLabel})`
|
||||
: 'Add a repo to create worktrees'}
|
||||
: 'Add a Git repo to create worktrees'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import StatusIndicator from './StatusIndicator'
|
|||
import WorktreeContextMenu from './WorktreeContextMenu'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { detectAgentStatusFromTitle } from '@/lib/agent-status'
|
||||
import { getRepoKindLabel, isFolderRepo } from '../../../../shared/repo-kind'
|
||||
import type {
|
||||
Worktree,
|
||||
Repo,
|
||||
|
|
@ -135,6 +136,7 @@ const WorktreeCard = React.memo(function WorktreeCard({
|
|||
const tabs = useAppStore((s) => s.tabsByWorktree[worktree.id] ?? EMPTY_TABS)
|
||||
|
||||
const branch = branchDisplayName(worktree.branch)
|
||||
const isFolder = repo ? isFolderRepo(repo) : false
|
||||
const prCacheKey = repo && branch ? `${repo.path}::${branch}` : ''
|
||||
const issueCacheKey = repo && worktree.linkedIssue ? `${repo.path}::${worktree.linkedIssue}` : ''
|
||||
|
||||
|
|
@ -175,15 +177,15 @@ const WorktreeCard = React.memo(function WorktreeCard({
|
|||
// This preference is purely presentational, so background refreshes would
|
||||
// spend rate limit budget on data the user cannot see.
|
||||
useEffect(() => {
|
||||
if (repo && !worktree.isBare && prCacheKey && (showPR || showCI)) {
|
||||
if (repo && !isFolder && !worktree.isBare && prCacheKey && (showPR || showCI)) {
|
||||
fetchPRForBranch(repo.path, branch)
|
||||
}
|
||||
}, [repo, worktree.isBare, fetchPRForBranch, branch, prCacheKey, showPR, showCI])
|
||||
}, [repo, isFolder, worktree.isBare, fetchPRForBranch, branch, prCacheKey, showPR, showCI])
|
||||
|
||||
// Same rationale for issues: once that section is hidden, polling only burns
|
||||
// GitHub calls and keeps stale-but-invisible data warm for no user benefit.
|
||||
useEffect(() => {
|
||||
if (!repo || !worktree.linkedIssue || !issueCacheKey || !showIssue) {
|
||||
if (!repo || isFolder || !worktree.linkedIssue || !issueCacheKey || !showIssue) {
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -195,7 +197,7 @@ const WorktreeCard = React.memo(function WorktreeCard({
|
|||
}, 5 * 60_000) // 5 minutes
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [repo, worktree.linkedIssue, fetchIssue, issueCacheKey, showIssue])
|
||||
}, [repo, isFolder, worktree.linkedIssue, fetchIssue, issueCacheKey, showIssue])
|
||||
|
||||
// Stable click handler – ignore clicks that are really text selections
|
||||
const handleClick = useCallback(() => {
|
||||
|
|
@ -329,7 +331,14 @@ const WorktreeCard = React.memo(function WorktreeCard({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{isPrimaryBranch(worktree.branch) ? (
|
||||
{isFolder ? (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="h-[16px] px-1.5 text-[10px] font-medium rounded shrink-0 text-muted-foreground bg-accent border border-border dark:bg-accent/80 dark:border-border/50 leading-none"
|
||||
>
|
||||
{repo ? getRepoKindLabel(repo) : 'Folder'}
|
||||
</Badge>
|
||||
) : isPrimaryBranch(worktree.branch) ? (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="h-[16px] px-1.5 text-[10px] font-medium rounded shrink-0 text-muted-foreground bg-accent border border-border dark:bg-accent/80 dark:border-border/50 leading-none"
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import {
|
|||
} from 'lucide-react'
|
||||
import { useAppStore } from '@/store'
|
||||
import type { Worktree } from '../../../../shared/types'
|
||||
import { isFolderRepo } from '../../../../shared/repo-kind'
|
||||
|
||||
type Props = {
|
||||
worktree: Worktree
|
||||
|
|
@ -30,6 +31,7 @@ const CLOSE_ALL_CONTEXT_MENUS_EVENT = 'orca-close-all-context-menus'
|
|||
const WorktreeContextMenu = React.memo(function WorktreeContextMenu({ worktree, children }: Props) {
|
||||
const updateWorktreeMeta = useAppStore((s) => s.updateWorktreeMeta)
|
||||
const openModal = useAppStore((s) => s.openModal)
|
||||
const repos = useAppStore((s) => s.repos)
|
||||
const shutdownWorktreeTerminals = useAppStore((s) => s.shutdownWorktreeTerminals)
|
||||
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
|
||||
const setActiveWorktree = useAppStore((s) => s.setActiveWorktree)
|
||||
|
|
@ -38,6 +40,8 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({ worktree,
|
|||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const [menuPoint, setMenuPoint] = useState({ x: 0, y: 0 })
|
||||
const isDeleting = deleteState?.isDeleting ?? false
|
||||
const repo = repos.find((entry) => entry.id === worktree.repoId)
|
||||
const isFolder = repo ? isFolderRepo(repo) : false
|
||||
|
||||
useEffect(() => {
|
||||
const closeMenu = (): void => setMenuOpen(false)
|
||||
|
|
@ -96,9 +100,26 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({ worktree,
|
|||
|
||||
const handleDelete = useCallback(() => {
|
||||
setMenuOpen(false)
|
||||
if (isFolder) {
|
||||
// Why: folder mode reuses the worktree row UI for a synthetic root entry,
|
||||
// but users still expect "remove" to disconnect the folder from Orca,
|
||||
// not to run git-style delete semantics against the real folder on disk.
|
||||
openModal('confirm-remove-folder', {
|
||||
repoId: worktree.repoId,
|
||||
displayName: worktree.displayName
|
||||
})
|
||||
return
|
||||
}
|
||||
clearWorktreeDeleteState(worktree.id)
|
||||
openModal('delete-worktree', { worktreeId: worktree.id })
|
||||
}, [worktree.id, clearWorktreeDeleteState, openModal])
|
||||
}, [
|
||||
worktree.id,
|
||||
worktree.repoId,
|
||||
worktree.displayName,
|
||||
clearWorktreeDeleteState,
|
||||
isFolder,
|
||||
openModal
|
||||
])
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -157,7 +178,7 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({ worktree,
|
|||
</DropdownMenuItem>
|
||||
<DropdownMenuItem variant="destructive" onSelect={handleDelete} disabled={isDeleting}>
|
||||
<Trash2 className="size-3.5" />
|
||||
{isDeleting ? 'Deleting…' : 'Delete'}
|
||||
{isDeleting ? 'Deleting…' : isFolder ? 'Remove Folder from Orca' : 'Delete'}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button'
|
|||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { Worktree, Repo } from '../../../../shared/types'
|
||||
import { isGitRepoKind } from '../../../../shared/repo-kind'
|
||||
import { buildWorktreeComparator } from './smart-sort'
|
||||
import { matchesSearch, type Row, buildRows, getGroupKeyForWorktree } from './worktree-list-groups'
|
||||
import { estimateRowHeight } from './worktree-list-estimate'
|
||||
|
|
@ -437,16 +438,19 @@ const WorktreeList = React.memo(function WorktreeList() {
|
|||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
if (row.repo) {
|
||||
if (row.repo && isGitRepoKind(row.repo)) {
|
||||
handleCreateForRepo(row.repo.id)
|
||||
}
|
||||
}}
|
||||
disabled={row.repo ? !isGitRepoKind(row.repo) : false}
|
||||
>
|
||||
<Plus className="size-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
Create worktree for {row.label}
|
||||
{row.repo && !isGitRepoKind(row.repo)
|
||||
? `${row.label} is opened as a folder`
|
||||
: `Create worktree for ${row.label}`}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ import SidebarToolbar from './SidebarToolbar'
|
|||
import AddWorktreeDialog from './AddWorktreeDialog'
|
||||
import WorktreeMetaDialog from './WorktreeMetaDialog'
|
||||
import DeleteWorktreeDialog from './DeleteWorktreeDialog'
|
||||
import NonGitFolderDialog from './NonGitFolderDialog'
|
||||
import RemoveFolderDialog from './RemoveFolderDialog'
|
||||
|
||||
const MIN_WIDTH = 220
|
||||
const MAX_WIDTH = 500
|
||||
|
|
@ -73,6 +75,8 @@ export default function Sidebar(): React.JSX.Element {
|
|||
<AddWorktreeDialog />
|
||||
<WorktreeMetaDialog />
|
||||
<DeleteWorktreeDialog />
|
||||
<NonGitFolderDialog />
|
||||
<RemoveFolderDialog />
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import type { StateCreator } from 'zustand'
|
|||
import { toast } from 'sonner'
|
||||
import type { AppState } from '../types'
|
||||
import type { Repo } from '../../../../shared/types'
|
||||
import { isGitRepoKind } from '../../../../shared/repo-kind'
|
||||
|
||||
const ERROR_TOAST_DURATION = 60_000
|
||||
|
||||
|
|
@ -10,10 +11,13 @@ export type RepoSlice = {
|
|||
activeRepoId: string | null
|
||||
fetchRepos: () => Promise<void>
|
||||
addRepo: () => Promise<Repo | null>
|
||||
addNonGitFolder: (path: string) => Promise<Repo | null>
|
||||
removeRepo: (repoId: string) => Promise<void>
|
||||
updateRepo: (
|
||||
repoId: string,
|
||||
updates: Partial<Pick<Repo, 'displayName' | 'badgeColor' | 'hookSettings' | 'worktreeBaseRef'>>
|
||||
updates: Partial<
|
||||
Pick<Repo, 'displayName' | 'badgeColor' | 'hookSettings' | 'worktreeBaseRef' | 'kind'>
|
||||
>
|
||||
) => Promise<void>
|
||||
setActiveRepo: (repoId: string | null) => void
|
||||
}
|
||||
|
|
@ -44,7 +48,22 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
|
|||
if (!path) {
|
||||
return null
|
||||
}
|
||||
const repo = await window.api.repos.add({ path })
|
||||
let repo: Repo
|
||||
try {
|
||||
repo = await window.api.repos.add({ path })
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
if (!message.includes('Not a valid git repository')) {
|
||||
throw err
|
||||
}
|
||||
// Why: folder mode is a capability downgrade, not a silent fallback.
|
||||
// Show an in-app confirmation dialog so users understand that worktrees,
|
||||
// SCM, PRs, and checks will be unavailable for this root. The dialog's
|
||||
// OK handler calls addNonGitFolder to complete the flow.
|
||||
const { openModal } = get()
|
||||
openModal('confirm-non-git-folder', { folderPath: path })
|
||||
return null
|
||||
}
|
||||
const alreadyAdded = get().repos.some((r) => r.id === repo.id)
|
||||
set((s) => {
|
||||
if (s.repos.some((r) => r.id === repo.id)) {
|
||||
|
|
@ -55,24 +74,43 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
|
|||
if (alreadyAdded) {
|
||||
toast.info('Repo already added', { description: repo.displayName })
|
||||
} else {
|
||||
toast.success('Repo added', { description: repo.displayName })
|
||||
toast.success(isGitRepoKind(repo) ? 'Repo added' : 'Folder added', {
|
||||
description: repo.displayName
|
||||
})
|
||||
}
|
||||
return repo
|
||||
} catch (err) {
|
||||
console.error('Failed to add repo:', err)
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
const duration = ERROR_TOAST_DURATION
|
||||
if (message.includes('Not a valid git repository')) {
|
||||
toast.error('Not a git repository', {
|
||||
description: 'Only git repositories can be added. Initialize one with git init first.',
|
||||
duration
|
||||
})
|
||||
toast.error('Failed to add repo', {
|
||||
description: message,
|
||||
duration
|
||||
})
|
||||
return null
|
||||
}
|
||||
},
|
||||
|
||||
addNonGitFolder: async (path) => {
|
||||
try {
|
||||
const repo = await window.api.repos.add({ path, kind: 'folder' })
|
||||
const alreadyAdded = get().repos.some((r) => r.id === repo.id)
|
||||
set((s) => {
|
||||
if (s.repos.some((r) => r.id === repo.id)) {
|
||||
return s
|
||||
}
|
||||
return { repos: [...s.repos, repo] }
|
||||
})
|
||||
if (alreadyAdded) {
|
||||
toast.info('Repo already added', { description: repo.displayName })
|
||||
} else {
|
||||
toast.error('Failed to add repo', {
|
||||
description: message,
|
||||
duration
|
||||
})
|
||||
toast.success('Folder added', { description: repo.displayName })
|
||||
}
|
||||
return repo
|
||||
} catch (err) {
|
||||
console.error('Failed to add folder:', err)
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
toast.error('Failed to add folder', { description: message, duration: ERROR_TOAST_DURATION })
|
||||
return null
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -29,7 +29,13 @@ export type UISlice = {
|
|||
} | null
|
||||
openSettingsTarget: (target: NonNullable<UISlice['settingsNavigationTarget']>) => void
|
||||
clearSettingsTarget: () => void
|
||||
activeModal: 'none' | 'create-worktree' | 'edit-meta' | 'delete-worktree'
|
||||
activeModal:
|
||||
| 'none'
|
||||
| 'create-worktree'
|
||||
| 'edit-meta'
|
||||
| 'delete-worktree'
|
||||
| 'confirm-non-git-folder'
|
||||
| 'confirm-remove-folder'
|
||||
modalData: Record<string, unknown>
|
||||
openModal: (modal: UISlice['activeModal'], data?: Record<string, unknown>) => void
|
||||
closeModal: () => void
|
||||
|
|
|
|||
17
src/shared/repo-kind.ts
Normal file
17
src/shared/repo-kind.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import type { Repo } from './types'
|
||||
|
||||
export function getRepoKind(repo: Pick<Repo, 'kind'>): 'git' | 'folder' {
|
||||
return repo.kind === 'folder' ? 'folder' : 'git'
|
||||
}
|
||||
|
||||
export function isFolderRepo(repo: Pick<Repo, 'kind'>): boolean {
|
||||
return getRepoKind(repo) === 'folder'
|
||||
}
|
||||
|
||||
export function isGitRepoKind(repo: Pick<Repo, 'kind'>): boolean {
|
||||
return getRepoKind(repo) === 'git'
|
||||
}
|
||||
|
||||
export function getRepoKindLabel(repo: Pick<Repo, 'kind'>): string {
|
||||
return isFolderRepo(repo) ? 'Folder' : 'Git'
|
||||
}
|
||||
|
|
@ -1,12 +1,15 @@
|
|||
/* eslint-disable max-lines */
|
||||
|
||||
// ─── Repo ────────────────────────────────────────────────────────────
|
||||
export type RepoKind = 'git' | 'folder'
|
||||
|
||||
export type Repo = {
|
||||
id: string
|
||||
path: string
|
||||
displayName: string
|
||||
badgeColor: string
|
||||
addedAt: number
|
||||
kind?: RepoKind
|
||||
gitUsername?: string
|
||||
worktreeBaseRef?: string
|
||||
hookSettings?: RepoHookSettings
|
||||
|
|
|
|||
Loading…
Reference in a new issue