feat: add non-git folder support (#356)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jinwoo Hong 2026-04-07 01:54:02 -04:00 committed by GitHub
parent 37ae8ce525
commit 9b2b85c59d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 1009 additions and 138 deletions

View 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

View file

@ -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))
}

View file

@ -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)

View file

@ -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,

View file

@ -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,

View file

@ -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 }
}

View file

@ -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(),

View 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)
})
})

View 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)
}

View file

@ -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,

View file

@ -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>

View file

@ -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),

View file

@ -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,

View file

@ -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" />

View file

@ -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

View file

@ -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

View file

@ -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>
)}

View file

@ -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)

View file

@ -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}

View file

@ -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)
}))

View file

@ -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"

View 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&apos;t a Git repository. You&apos;ll have the editor, terminal, and
search, but Git-based features won&apos;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

View 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

View file

@ -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>

View file

@ -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"

View file

@ -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>

View file

@ -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}

View file

@ -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>
)
}

View file

@ -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
}
},

View file

@ -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
View 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'
}

View file

@ -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