import { z } from "zod"; // ============================================================================ // Configuration Types // ============================================================================ export const DirectoryConfigSchema = z.object({ path: z.string().min(1), maxDepth: z.number().int().min(0).max(10).default(2), label: z.string().optional(), editor: z.string().optional(), }); // Reserved keys that cannot be used for custom commands export const RESERVED_KEYS = new Set([ // Navigation "j", "k", "g", "G", // Selection " ", "a", // View/Filter "/", "s", "S", "F", "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", // Git operations "p", "P", "f", "i", // GitHub operations "c", "C", "A", "D", // General "r", "?", "q", // Modal actions (used in detail modal) "o", "d", // Command palette trigger "x", ]); export const CommandConfigSchema = z.object({ name: z.string().min(1), key: z.string().length(1).refine( (key) => !RESERVED_KEYS.has(key), (key) => ({ message: `Key '${key}' is reserved and cannot be used for custom commands` }) ), command: z.string().min(1), // Custom commands run through `sh -c` with the user's account privileges. // Require an explicit confirmation unless a config opts out intentionally. confirm: z.boolean().default(true), background: z.boolean().default(false), }); export type CommandConfig = z.infer; export const GitforestConfigSchema = z.object({ directories: z.array(DirectoryConfigSchema).min(1), editor: z.string().optional(), // Global editor setting scan: z .object({ ignore: z .array(z.string()) .default(["node_modules", ".git", "vendor", "__pycache__", "target", "dist", "build"]), includeHidden: z.boolean().default(false), concurrency: z.number().int().min(1).max(20).default(5), }) .default({}), github: z .object({ defaultVisibility: z.enum(["private", "public"]).default("private"), }) .default({}), display: z .object({ showSubmodules: z.boolean().default(true), showNonGitProjects: z.boolean().default(true), // Show non-git projects (folders with project markers but no .git) sortBy: z.enum(["status", "name", "branch", "sync", "language", "stars", "forks", "lastActivity", "size"]).default("status"), sortDirection: z.enum(["asc", "desc"]).default("desc"), }) .default({}), cache: z .object({ ttlSeconds: z.number().int().positive().default(300), githubTtlSeconds: z.number().int().positive().default(600), enableBackgroundRefresh: z.boolean().default(true), backgroundRefreshIntervalSeconds: z.number().int().positive().default(300), }) .default({}), commands: z .array(CommandConfigSchema) .default([]) .refine( (commands) => { const keys = commands.map((c) => c.key); return new Set(keys).size === keys.length; }, { message: "Duplicate command keys are not allowed" } ), }); export type DirectoryConfig = z.infer; export type GitforestConfig = z.infer; // ============================================================================ // Project Types // ============================================================================ export type ProjectType = "git" | "git-submodule" | "non-git"; export interface SubmoduleInfo { parentPath: string; relativePath: string; configuredCommit: string; currentCommit: string; isInitialized: boolean; } export interface GitStatus { // Working tree status hasUnstagedChanges: boolean; hasStagedChanges: boolean; hasUntrackedFiles: boolean; modifiedCount: number; stagedCount: number; untrackedCount: number; // Sync status currentBranch: string; trackingBranch: string | null; unpushedCommits: number; unpulledCommits: number; // Remote info hasRemote: boolean; remoteUrl: string | null; // Activity timestamps lastLocalCommit: Date | null; lastRemoteActivity: Date | null; // Repository state hasCommits: boolean; // Computed flags isDirty: boolean; isAhead: boolean; isBehind: boolean; isOutOfSync: boolean; } /** * Status fields derived from the working tree, local refs, and config. * Change when the user touches files, commits locally, or edits config. * Read cheaply on every scan — no network. * * See ADR-0005. */ export interface LocalStatus { hasUnstagedChanges: boolean; hasStagedChanges: boolean; hasUntrackedFiles: boolean; modifiedCount: number; stagedCount: number; untrackedCount: number; currentBranch: string; trackingBranch: string | null; hasRemote: boolean; remoteUrl: string | null; lastLocalCommit: Date | null; hasCommits: boolean; isDirty: boolean; } /** * Status fields derived from the tracking ref (`origin/`). * Change only when `git fetch` runs. `null` when the repo has no remote * or no tracking branch. * * See ADR-0005. */ export interface RemoteStatus { unpushedCommits: number; unpulledCommits: number; lastRemoteActivity: Date | null; isAhead: boolean; isBehind: boolean; isOutOfSync: boolean; } export interface Project { id: string; name: string; path: string; type: ProjectType; projectMarker: string | null; // e.g., "package.json", "Cargo.toml" status: GitStatus | null; submodule: SubmoduleInfo | null; lastScanned: Date; lastModified: Date | null; // For non-git: most recent file modification time } // ============================================================================ // Project Markers // ============================================================================ export const PROJECT_MARKERS: Record = { "package.json": "Node.js", "Cargo.toml": "Rust", "pyproject.toml": "Python", "setup.py": "Python", "go.mod": "Go", "Gemfile": "Ruby", "pom.xml": "Java (Maven)", "build.gradle": "Java (Gradle)", "composer.json": "PHP", "mix.exs": "Elixir", "pubspec.yaml": "Dart/Flutter", "CMakeLists.txt": "C/C++", "Makefile": "Make", "flake.nix": "Nix", "deno.json": "Deno", "bun.lockb": "Bun", }; // ============================================================================ // App State Types // ============================================================================ export type SortField = | "status" | "name" | "branch" | "sync" | "language" | "stars" | "forks" | "lastActivity" | "size"; export type SortDirection = "asc" | "desc"; export type AppMode = "normal" | "filter" | "action" | "help" | "filter-options" | "command-palette"; // Quick filter for status-based filtering (1=dirty, 2=unpushed, 3=no-remote, 0=all) export type QuickFilter = | "all" | "dirty" | "unpushed" | "no-remote" | "github-only" | "local-only" | "private" | "public" | "archived" | "forks"; export interface ConfirmDialogState { operation: "setup" | "create" | "archive" | "command"; title: string; message: string; items: string[]; projectPaths: string[]; showVisibilityToggle: boolean; command?: CommandConfig; } export interface CloneDialogState { repos: UnifiedRepo[]; directories: DirectoryConfig[]; selectedDirIndex: number; useSSH: boolean; } /** * Modal payload for kind="add-directory". The rich step-machine state * (step / inputs / error) lives in the `useAddDirectoryFlow` hook now, * so this payload is just the seed `initialPath` (if any). */ export interface AddDirectoryDialogState { initialPath?: string; } export interface DetailModalState { repo: UnifiedRepo; readmeContent: string | null; readmeLoading: boolean; readmeError: string | null; readmeScrollOffset: number; } export interface AppState { // Data projects: Project[]; isLoading: boolean; error: string | null; message: string | null; // Selection cursorIndex: number; selectedIndices: Set; scrollOffset: number; // Filtering & Sorting filterText: string; quickFilter: QuickFilter; sortBy: SortField; sortDirection: SortDirection; // UI state mode: AppMode; actionInProgress: string | null; actionProgress: { current: number; total: number } | null; } // ============================================================================ // Modal State (ADR-0012) // // All open-modal state is unified into one tagged-union `modal` field on // UnifiedAppState. Each variant carries its own data, so the type system // rules out the previously-possible inconsistent pairing of `mode === "X"` // with a different dialog slot populated. AppMode shrinks to the set of // non-modal keyboard contexts. // // All modal variants now live on this union: confirm, clone, detail, and // add-directory. // ============================================================================ export type ModalState = | { kind: "none" } | { kind: "confirm"; data: ConfirmDialogState } | { kind: "clone"; data: CloneDialogState } | { kind: "detail"; data: DetailModalState } | { kind: "add-directory"; data: AddDirectoryDialogState }; export type ModalKind = ModalState["kind"]; export type OpenModalState = Exclude; /** * Payload for UPDATE_MODAL — narrowed by `kind`. The reducer drops the * update silently when `state.modal.kind` doesn't match the payload's kind. */ export type ModalUpdate = | { kind: "confirm"; data: Partial } | { kind: "clone"; data: Partial } | { kind: "detail"; data: Partial } | { kind: "add-directory"; data: Partial }; export type AppAction = | { type: "SET_PROJECTS"; payload: Project[] } | { type: "SET_LOADING"; payload: boolean } | { type: "SET_ERROR"; payload: string | null } | { type: "SET_MESSAGE"; payload: string | null } | { type: "MOVE_CURSOR"; payload: { index: number; maxIndex: number } } | { type: "TOGGLE_SELECTION"; payload: number } | { type: "SELECT_ALL"; payload: { count: number } } | { type: "DESELECT_ALL" } | { type: "SET_FILTER"; payload: string } | { type: "SET_QUICK_FILTER"; payload: QuickFilter } | { type: "SET_SORT"; payload: { by: SortField; direction: SortDirection } } | { type: "CYCLE_SORT" } | { type: "SET_MODE"; payload: AppMode } | { type: "START_ACTION"; payload: string } | { type: "END_ACTION" } | { type: "UPDATE_PROGRESS"; payload: { current: number; total: number } } | { type: "SET_SCROLL_OFFSET"; payload: number } | { type: "UPDATE_PROJECT"; payload: { id: string; updates: Partial } } | { type: "UPDATE_PROJECT_REMOTE_STATUS"; payload: { path: string; remote: RemoteStatus | null } }; // ============================================================================ // Operation Results // ============================================================================ export interface OperationResult { success: boolean; projectPath: string; operation: string; message?: string; error?: string; duration: number; } export interface BatchResult { total: number; successful: number; failed: number; results: OperationResult[]; duration: number; } // ============================================================================ // GitHub Types // ============================================================================ export interface GitHubRepoInfo { name: string; fullName: string; owner: string; description: string | null; htmlUrl: string; sshUrl: string; cloneUrl: string; isPrivate: boolean; isArchived: boolean; isFork: boolean; pushedAt: Date | null; updatedAt: Date | null; defaultBranch: string; language: string | null; /** Repository size in kilobytes, as reported by the GitHub API. */ size: number; stargazersCount?: number; forksCount?: number; openIssuesCount?: number; watchersCount?: number; topics?: string[]; license?: string | null; hasIssues?: boolean; hasWiki?: boolean; hasDiscussions?: boolean; } // ============================================================================ // Unified View Types (Local + GitHub) // ============================================================================ export type ViewMode = "local" | "github" | "combined"; export type RepoSource = "local" | "github" | "both"; /** * Unified item that can represent: * - A local project (may or may not be on GitHub) * - A GitHub repo (may or may not be cloned locally) */ export interface UnifiedRepo { id: string; name: string; source: RepoSource; // Local project info (if exists locally) local: Project | null; // GitHub repo info (if exists on GitHub) github: GitHubRepoInfo | null; // Sync status between local and GitHub isCloned: boolean; isOnGitHub: boolean; localPath: string | null; } // Extended AppState for unified view export interface UnifiedAppState extends AppState { viewMode: ViewMode; githubRepos: GitHubRepoInfo[]; unifiedRepos: UnifiedRepo[]; isLoadingGitHub: boolean; githubError: string | null; isRefreshing: boolean; // Background refresh indicator modal: ModalState; languageFilter: string | null; } export type UnifiedAppAction = | AppAction | { type: "SET_REPO_DATA"; payload: { projects: Project[]; githubRepos: GitHubRepoInfo[]; unifiedRepos: UnifiedRepo[]; finishInitialLoading: boolean; }; } | { type: "SET_VIEW_MODE"; payload: ViewMode } | { type: "SET_GITHUB_REPOS"; payload: GitHubRepoInfo[] } | { type: "SET_UNIFIED_REPOS"; payload: UnifiedRepo[] } | { type: "SET_GITHUB_LOADING"; payload: boolean } | { type: "SET_GITHUB_ERROR"; payload: string | null } | { type: "SET_REFRESHING"; payload: boolean } | { type: "CLONE_REPO_START"; payload: string } | { type: "CLONE_REPO_COMPLETE"; payload: { id: string; localPath: string } } | { type: "CLONE_REPO_FAILED"; payload: { id: string; error: string } } | { type: "SET_LANGUAGE_FILTER"; payload: string | null } | { type: "OPEN_MODAL"; payload: OpenModalState } | { type: "CLOSE_MODAL" } | { type: "UPDATE_MODAL"; payload: ModalUpdate };