sync-worktrees
Version:
Automatically synchronize Git worktrees with remote branches - perfect for multi-branch development workflows
928 lines (918 loc) • 178 kB
JavaScript
#!/usr/bin/env node
// src/index.ts
import * as path10 from "path";
import { confirm as confirm2 } from "@inquirer/prompts";
import * as cron2 from "node-cron";
import pLimit2 from "p-limit";
// src/constants.ts
var GIT_CONSTANTS = {
REMOTE_PREFIX: "origin/",
REMOTE_NAME: "origin",
HEAD_REF: "/HEAD",
DEFAULT_BRANCH: "main",
COMMON_DEFAULT_BRANCHES: ["main", "master", "develop", "trunk"],
BARE_DIR_NAME: ".bare",
DIVERGED_DIR_NAME: ".diverged",
LFS_HEADER: "version https://git-lfs.github.com/spec/",
SUBMODULE_STATUS_ADDED: "+",
SUBMODULE_STATUS_REMOVED: "-",
GITDIR_PREFIX: "gitdir:",
GIT_CHECK_IGNORE_NO_MATCH: "exit code: 1",
REFS: {
HEADS: "refs/heads/",
REMOTES: "refs/remotes/origin",
REMOTES_ORIGIN: "refs/remotes/origin/*"
},
FETCH_CONFIG: "+refs/heads/*:refs/remotes/origin/*"
};
var GIT_OPERATIONS = {
MERGE_HEAD: "MERGE_HEAD",
CHERRY_PICK_HEAD: "CHERRY_PICK_HEAD",
REVERT_HEAD: "REVERT_HEAD",
BISECT_LOG: "BISECT_LOG",
REBASE_MERGE: "rebase-merge",
REBASE_APPLY: "rebase-apply"
};
var DEFAULT_CONFIG = {
CRON_SCHEDULE: "0 * * * *",
RETRY: {
MAX_ATTEMPTS: 3,
MAX_LFS_RETRIES: 2,
INITIAL_DELAY_MS: 1e3,
MAX_DELAY_MS: 3e4,
BACKOFF_MULTIPLIER: 2,
JITTER_MS: 500
},
PARALLELISM: {
MAX_REPOSITORIES: 2,
MAX_WORKTREE_CREATION: 1,
MAX_WORKTREE_UPDATES: 3,
MAX_WORKTREE_REMOVAL: 3,
MAX_STATUS_CHECKS: 20,
MAX_SAFE_TOTAL_CONCURRENT_OPS: 100
},
UPDATE_EXISTING_WORKTREES: true
};
var ERROR_MESSAGES = {
GIT_NOT_INITIALIZED: "Git service not initialized. Call initialize() first.",
ALREADY_EXISTS: "already exists",
ALREADY_REGISTERED: "already registered worktree",
FAST_FORWARD_FAILED: [
"Not possible to fast-forward",
"fatal: Not possible to fast-forward, aborting",
"cannot fast-forward"
],
NO_UPSTREAM: [
"fatal: no upstream configured",
"no upstream configured for branch",
"fatal: ambiguous argument",
"unknown revision or path"
],
LFS_ERROR: ["smudge filter lfs failed", "git-lfs", "LFS"],
EXDEV: "EXDEV"
};
var ENV_CONSTANTS = {
GIT_LFS_SKIP_SMUDGE: "GIT_LFS_SKIP_SMUDGE",
NODE_ENV_TEST: "test"
};
var PATH_CONSTANTS = {
GIT_DIR: ".git",
README: "README"
};
var CONFIG_CONSTANTS = {
WILDCARD_PATTERN: ".*"
};
var METADATA_CONSTANTS = {
MAX_HISTORY_ENTRIES: 10,
METADATA_FILENAME: "sync-metadata.json",
WORKTREE_METADATA_PATH: ".git/worktrees",
DIVERGED_INFO_FILE: ".diverged-info.json",
DIVERGED_REASON: "diverged-history-with-changes",
ACTION_CREATED: "created",
ACTION_UPDATED: "updated",
ACTION_FETCHED: "fetched"
};
// src/services/config-loader.service.ts
import * as fs from "fs/promises";
import * as path from "path";
import { pathToFileURL } from "url";
var ConfigLoaderService = class {
async loadConfigFile(configPath) {
const absolutePath = path.resolve(configPath);
try {
await fs.access(absolutePath);
} catch {
throw new Error(`Config file not found: ${absolutePath}`);
}
try {
const fileUrl = pathToFileURL(absolutePath);
fileUrl.searchParams.set("t", Date.now().toString());
const configModule = await import(fileUrl.href);
const config = configModule.default;
if (!config) {
throw new Error("Config file must use 'export default' syntax");
}
this.validateConfigFile(config);
return config;
} catch (error) {
if (error instanceof Error && error.message.includes("Config file not found")) {
throw error;
}
throw new Error(`Failed to load config file: ${error.message}`);
}
}
validateConfigFile(config) {
if (!config || typeof config !== "object") {
throw new Error("Config file must export an object");
}
const configObj = config;
if (!Array.isArray(configObj.repositories)) {
throw new Error("Config file must have a 'repositories' array");
}
if (configObj.repositories.length === 0) {
throw new Error("Config file must have at least one repository");
}
const seenNames = /* @__PURE__ */ new Set();
configObj.repositories.forEach((repo, index) => {
if (!repo || typeof repo !== "object") {
throw new Error(`Repository at index ${index} must be an object`);
}
const repoObj = repo;
if (!repoObj.name || typeof repoObj.name !== "string") {
throw new Error(`Repository at index ${index} must have a 'name' property`);
}
if (seenNames.has(repoObj.name)) {
throw new Error(`Duplicate repository name: ${repoObj.name}`);
}
seenNames.add(repoObj.name);
if (!repoObj.repoUrl || typeof repoObj.repoUrl !== "string") {
throw new Error(`Repository '${repoObj.name}' must have a 'repoUrl' property`);
}
if (!repoObj.worktreeDir || typeof repoObj.worktreeDir !== "string") {
throw new Error(`Repository '${repoObj.name}' must have a 'worktreeDir' property`);
}
if (repoObj.bareRepoDir !== void 0 && typeof repoObj.bareRepoDir !== "string") {
throw new Error(`Repository '${repoObj.name}' has invalid 'bareRepoDir' property`);
}
if (repoObj.cronSchedule !== void 0 && typeof repoObj.cronSchedule !== "string") {
throw new Error(`Repository '${repoObj.name}' has invalid 'cronSchedule' property`);
}
if (repoObj.runOnce !== void 0 && typeof repoObj.runOnce !== "boolean") {
throw new Error(`Repository '${repoObj.name}' has invalid 'runOnce' property`);
}
if (repoObj.filesToCopyOnBranchCreate !== void 0) {
this.validateFilesToCopyConfig(repoObj.filesToCopyOnBranchCreate, `Repository '${repoObj.name}'`);
}
});
if (configObj.defaults) {
if (typeof configObj.defaults !== "object") {
throw new Error("'defaults' must be an object");
}
const defaults = configObj.defaults;
if (defaults.cronSchedule !== void 0 && typeof defaults.cronSchedule !== "string") {
throw new Error("Invalid 'cronSchedule' in defaults");
}
if (defaults.runOnce !== void 0 && typeof defaults.runOnce !== "boolean") {
throw new Error("Invalid 'runOnce' in defaults");
}
if (defaults.retry !== void 0 && typeof defaults.retry !== "object") {
throw new Error("Invalid 'retry' in defaults");
}
if (defaults.filesToCopyOnBranchCreate !== void 0) {
this.validateFilesToCopyConfig(defaults.filesToCopyOnBranchCreate, "defaults");
}
}
if (configObj.retry !== void 0) {
if (typeof configObj.retry !== "object") {
throw new Error("'retry' must be an object");
}
const retry2 = configObj.retry;
if (retry2.maxAttempts !== void 0) {
if (retry2.maxAttempts !== "unlimited" && (typeof retry2.maxAttempts !== "number" || retry2.maxAttempts < 1)) {
throw new Error("Invalid 'maxAttempts' in retry config. Must be 'unlimited' or a positive number");
}
}
if (retry2.maxLfsRetries !== void 0) {
if (typeof retry2.maxLfsRetries !== "number" || retry2.maxLfsRetries < 0) {
throw new Error("Invalid 'maxLfsRetries' in retry config. Must be a non-negative number");
}
}
if (retry2.initialDelayMs !== void 0 && (typeof retry2.initialDelayMs !== "number" || retry2.initialDelayMs < 0)) {
throw new Error("Invalid 'initialDelayMs' in retry config");
}
if (retry2.maxDelayMs !== void 0 && (typeof retry2.maxDelayMs !== "number" || retry2.maxDelayMs < 0)) {
throw new Error("Invalid 'maxDelayMs' in retry config");
}
if (retry2.backoffMultiplier !== void 0 && (typeof retry2.backoffMultiplier !== "number" || retry2.backoffMultiplier < 1)) {
throw new Error("Invalid 'backoffMultiplier' in retry config");
}
}
if (configObj.parallelism !== void 0) {
this.validateParallelismConfig(configObj.parallelism, "global");
}
if (configObj.defaults && typeof configObj.defaults === "object") {
const defaults = configObj.defaults;
if (defaults.parallelism !== void 0) {
this.validateParallelismConfig(defaults.parallelism, "defaults");
}
}
}
validateParallelismConfig(parallelism, context) {
if (typeof parallelism !== "object" || parallelism === null) {
throw new Error(`'parallelism' in ${context} must be an object`);
}
const config = parallelism;
if (config.maxRepositories !== void 0) {
if (typeof config.maxRepositories !== "number" || config.maxRepositories < 1) {
throw new Error(`Invalid 'maxRepositories' in ${context} parallelism config. Must be a positive number`);
}
}
if (config.maxWorktreeCreation !== void 0) {
if (typeof config.maxWorktreeCreation !== "number" || config.maxWorktreeCreation < 1) {
throw new Error(`Invalid 'maxWorktreeCreation' in ${context} parallelism config. Must be a positive number`);
}
}
if (config.maxWorktreeUpdates !== void 0) {
if (typeof config.maxWorktreeUpdates !== "number" || config.maxWorktreeUpdates < 1) {
throw new Error(`Invalid 'maxWorktreeUpdates' in ${context} parallelism config. Must be a positive number`);
}
}
if (config.maxWorktreeRemoval !== void 0) {
if (typeof config.maxWorktreeRemoval !== "number" || config.maxWorktreeRemoval < 1) {
throw new Error(`Invalid 'maxWorktreeRemoval' in ${context} parallelism config. Must be a positive number`);
}
}
if (config.maxStatusChecks !== void 0) {
if (typeof config.maxStatusChecks !== "number" || config.maxStatusChecks < 1) {
throw new Error(`Invalid 'maxStatusChecks' in ${context} parallelism config. Must be a positive number`);
}
}
const maxRepos = config.maxRepositories ?? DEFAULT_CONFIG.PARALLELISM.MAX_REPOSITORIES;
const maxCreation = config.maxWorktreeCreation ?? DEFAULT_CONFIG.PARALLELISM.MAX_WORKTREE_CREATION;
const maxUpdates = config.maxWorktreeUpdates ?? DEFAULT_CONFIG.PARALLELISM.MAX_WORKTREE_UPDATES;
const maxRemoval = config.maxWorktreeRemoval ?? DEFAULT_CONFIG.PARALLELISM.MAX_WORKTREE_REMOVAL;
const maxStatus = config.maxStatusChecks ?? DEFAULT_CONFIG.PARALLELISM.MAX_STATUS_CHECKS;
const maxPerRepoOps = maxCreation + maxUpdates + maxRemoval + maxStatus;
const totalMaxConcurrent = maxRepos * maxPerRepoOps;
if (totalMaxConcurrent > DEFAULT_CONFIG.PARALLELISM.MAX_SAFE_TOTAL_CONCURRENT_OPS) {
const safeMaxRepos = Math.floor(DEFAULT_CONFIG.PARALLELISM.MAX_SAFE_TOTAL_CONCURRENT_OPS / maxPerRepoOps);
throw new Error(
`Total concurrent operations (${totalMaxConcurrent}) exceeds safe limit (${DEFAULT_CONFIG.PARALLELISM.MAX_SAFE_TOTAL_CONCURRENT_OPS}). With current per-repository limits (creation: ${maxCreation}, updates: ${maxUpdates}, removal: ${maxRemoval}, status: ${maxStatus}), maximum safe maxRepositories is ${safeMaxRepos}. Consider reducing maxRepositories or lowering per-operation limits.`
);
}
}
validateFilesToCopyConfig(filesToCopy, context) {
if (!Array.isArray(filesToCopy)) {
throw new Error(`'filesToCopyOnBranchCreate' in ${context} must be an array`);
}
for (let i = 0; i < filesToCopy.length; i++) {
const pattern = filesToCopy[i];
if (typeof pattern !== "string" || pattern.trim() === "") {
throw new Error(
`'filesToCopyOnBranchCreate' in ${context} must contain only non-empty strings (invalid at index ${i})`
);
}
}
}
resolveRepositoryConfig(repo, defaults, configDir, globalRetry) {
const resolved = {
name: repo.name,
repoUrl: repo.repoUrl,
worktreeDir: this.resolvePath(repo.worktreeDir, configDir),
cronSchedule: repo.cronSchedule ?? defaults?.cronSchedule ?? DEFAULT_CONFIG.CRON_SCHEDULE,
runOnce: repo.runOnce ?? defaults?.runOnce ?? false
};
if (repo.bareRepoDir) {
resolved.bareRepoDir = this.resolvePath(repo.bareRepoDir, configDir);
}
if (repo.branchMaxAge || defaults?.branchMaxAge) {
resolved.branchMaxAge = repo.branchMaxAge ?? defaults?.branchMaxAge;
}
if (repo.skipLfs !== void 0 || defaults?.skipLfs !== void 0) {
resolved.skipLfs = repo.skipLfs ?? defaults?.skipLfs ?? false;
}
if (repo.retry || defaults?.retry || globalRetry) {
resolved.retry = {
...globalRetry || {},
...defaults?.retry || {},
...repo.retry || {}
};
}
if (repo.parallelism || defaults?.parallelism) {
resolved.parallelism = {
...defaults?.parallelism || {},
...repo.parallelism || {}
};
}
if (repo.updateExistingWorktrees !== void 0 || defaults?.updateExistingWorktrees !== void 0) {
resolved.updateExistingWorktrees = repo.updateExistingWorktrees ?? defaults?.updateExistingWorktrees ?? true;
}
if (repo.filesToCopyOnBranchCreate || defaults?.filesToCopyOnBranchCreate) {
resolved.filesToCopyOnBranchCreate = repo.filesToCopyOnBranchCreate ?? defaults?.filesToCopyOnBranchCreate;
}
return resolved;
}
resolvePath(inputPath, baseDir) {
if (path.isAbsolute(inputPath)) {
return inputPath;
}
return path.resolve(baseDir || process.cwd(), inputPath);
}
filterRepositories(repositories, filter) {
if (!filter) {
return repositories;
}
const patterns = filter.split(",").map((p) => p.trim());
return repositories.filter((repo) => {
return patterns.some((pattern) => {
if (pattern.includes("*")) {
const regex = new RegExp("^" + pattern.replace(/\*/g, CONFIG_CONSTANTS.WILDCARD_PATTERN) + "$");
return regex.test(repo.name);
}
return repo.name === pattern;
});
});
}
};
// src/services/InteractiveUIService.tsx
import React7 from "react";
import * as path7 from "path";
import { render } from "ink";
import * as cron from "node-cron";
import { spawn } from "child_process";
// src/components/App.tsx
import React6, { useState as useState5, useEffect as useEffect5, useCallback as useCallback3, useRef as useRef3 } from "react";
import { Box as Box6, useInput as useInput5, useStdout } from "ink";
// src/components/StatusBar.tsx
import React, { useState, useEffect } from "react";
import { Box, Text } from "ink";
import { CronExpressionParser } from "cron-parser";
var StatusBar = ({ status, repositoryCount, lastSyncTime, cronSchedule, diskSpaceUsed }) => {
const [nextSyncTime, setNextSyncTime] = useState(null);
useEffect(() => {
if (!cronSchedule) {
setNextSyncTime(null);
return void 0;
}
try {
const interval = CronExpressionParser.parse(cronSchedule);
setNextSyncTime(interval.next().toDate());
const timer = setInterval(() => {
const nextInterval = CronExpressionParser.parse(cronSchedule);
setNextSyncTime(nextInterval.next().toDate());
}, 6e4);
return () => clearInterval(timer);
} catch (error) {
setNextSyncTime(null);
return void 0;
}
}, [cronSchedule]);
const formatTime = (date) => {
if (!date) return "N/A";
return date.toLocaleTimeString();
};
const getStatusColor = () => {
return status === "syncing" ? "yellow" : "green";
};
const getStatusIcon = () => {
return status === "syncing" ? "\u27F3" : "\u2713";
};
return /* @__PURE__ */ React.createElement(Box, { borderStyle: "single", paddingX: 1 }, /* @__PURE__ */ React.createElement(Box, { flexDirection: "column", width: "100%" }, /* @__PURE__ */ React.createElement(Box, { justifyContent: "space-between" }, /* @__PURE__ */ React.createElement(Text, { bold: true }, getStatusIcon(), " Status:", " ", /* @__PURE__ */ React.createElement(Text, { color: getStatusColor() }, status === "syncing" ? "Syncing..." : "Running")), /* @__PURE__ */ React.createElement(Text, null, "Repositories: ", /* @__PURE__ */ React.createElement(Text, { bold: true, color: "cyan" }, repositoryCount))), /* @__PURE__ */ React.createElement(Box, { justifyContent: "space-between" }, /* @__PURE__ */ React.createElement(Text, null, "Last Sync: ", /* @__PURE__ */ React.createElement(Text, { color: "gray" }, formatTime(lastSyncTime))), cronSchedule && /* @__PURE__ */ React.createElement(Text, null, "Next Sync: ", /* @__PURE__ */ React.createElement(Text, { color: "gray" }, formatTime(nextSyncTime)))), /* @__PURE__ */ React.createElement(Box, { justifyContent: "space-between" }, /* @__PURE__ */ React.createElement(Text, null, "Disk Space: ", /* @__PURE__ */ React.createElement(Text, { color: "magenta" }, diskSpaceUsed || "Calculating...")), /* @__PURE__ */ React.createElement(Text, { dimColor: true }, /* @__PURE__ */ React.createElement(Text, { color: "yellow" }, "s"), "ync", " ", /* @__PURE__ */ React.createElement(Text, { color: "yellow" }, "c"), "reate", " ", /* @__PURE__ */ React.createElement(Text, { color: "yellow" }, "o"), "pen", " ", /* @__PURE__ */ React.createElement(Text, { color: "yellow" }, "r"), "eload", " ", /* @__PURE__ */ React.createElement(Text, { color: "yellow" }, "?"), "help", " ", /* @__PURE__ */ React.createElement(Text, { color: "yellow" }, "q"), "uit"))));
};
var StatusBar_default = StatusBar;
// src/components/HelpModal.tsx
import React2 from "react";
import { Box as Box2, Text as Text2, useInput } from "ink";
var HelpModal = ({ onClose }) => {
useInput(() => {
onClose();
});
return /* @__PURE__ */ React2.createElement(Box2, { justifyContent: "center", alignItems: "center", flexDirection: "column", marginTop: 2, marginBottom: 2 }, /* @__PURE__ */ React2.createElement(Box2, { borderStyle: "double", borderColor: "cyan", paddingX: 2, paddingY: 1, flexDirection: "column", width: 60 }, /* @__PURE__ */ React2.createElement(Box2, { justifyContent: "center", marginBottom: 1 }, /* @__PURE__ */ React2.createElement(Text2, { bold: true, color: "cyan" }, "\u{1F333} sync-worktrees - Keyboard Shortcuts")), /* @__PURE__ */ React2.createElement(Box2, { flexDirection: "column", gap: 0 }, /* @__PURE__ */ React2.createElement(Text2, { bold: true, color: "green", dimColor: true }, "Navigation"), /* @__PURE__ */ React2.createElement(Box2, null, /* @__PURE__ */ React2.createElement(Box2, { width: 15 }, /* @__PURE__ */ React2.createElement(Text2, { bold: true, color: "yellow" }, "j"), /* @__PURE__ */ React2.createElement(Text2, null, " / "), /* @__PURE__ */ React2.createElement(Text2, { bold: true, color: "yellow" }, "\u2193")), /* @__PURE__ */ React2.createElement(Text2, null, "Scroll down one line")), /* @__PURE__ */ React2.createElement(Box2, null, /* @__PURE__ */ React2.createElement(Box2, { width: 15 }, /* @__PURE__ */ React2.createElement(Text2, { bold: true, color: "yellow" }, "k"), /* @__PURE__ */ React2.createElement(Text2, null, " / "), /* @__PURE__ */ React2.createElement(Text2, { bold: true, color: "yellow" }, "\u2191")), /* @__PURE__ */ React2.createElement(Text2, null, "Scroll up one line")), /* @__PURE__ */ React2.createElement(Box2, null, /* @__PURE__ */ React2.createElement(Box2, { width: 15 }, /* @__PURE__ */ React2.createElement(Text2, { bold: true, color: "yellow" }, "gg")), /* @__PURE__ */ React2.createElement(Text2, null, "Jump to top")), /* @__PURE__ */ React2.createElement(Box2, null, /* @__PURE__ */ React2.createElement(Box2, { width: 15 }, /* @__PURE__ */ React2.createElement(Text2, { bold: true, color: "yellow" }, "G")), /* @__PURE__ */ React2.createElement(Text2, null, "Jump to bottom (re-enables auto-scroll)")), /* @__PURE__ */ React2.createElement(Box2, { marginTop: 1 }, /* @__PURE__ */ React2.createElement(Text2, { bold: true, color: "green", dimColor: true }, "Actions")), /* @__PURE__ */ React2.createElement(Box2, null, /* @__PURE__ */ React2.createElement(Box2, { width: 15 }, /* @__PURE__ */ React2.createElement(Text2, { bold: true, color: "yellow" }, "s")), /* @__PURE__ */ React2.createElement(Text2, null, "Manually trigger sync for all repositories")), /* @__PURE__ */ React2.createElement(Box2, null, /* @__PURE__ */ React2.createElement(Box2, { width: 15 }, /* @__PURE__ */ React2.createElement(Text2, { bold: true, color: "yellow" }, "c")), /* @__PURE__ */ React2.createElement(Text2, null, "Create a new branch")), /* @__PURE__ */ React2.createElement(Box2, null, /* @__PURE__ */ React2.createElement(Box2, { width: 15 }, /* @__PURE__ */ React2.createElement(Text2, { bold: true, color: "yellow" }, "o")), /* @__PURE__ */ React2.createElement(Text2, null, "Open editor in worktree")), /* @__PURE__ */ React2.createElement(Box2, null, /* @__PURE__ */ React2.createElement(Box2, { width: 15 }, /* @__PURE__ */ React2.createElement(Text2, { bold: true, color: "yellow" }, "r")), /* @__PURE__ */ React2.createElement(Text2, null, "Reload configuration and re-sync all repos")), /* @__PURE__ */ React2.createElement(Box2, null, /* @__PURE__ */ React2.createElement(Box2, { width: 15 }, /* @__PURE__ */ React2.createElement(Text2, { bold: true, color: "yellow" }, "?"), /* @__PURE__ */ React2.createElement(Text2, null, " / "), /* @__PURE__ */ React2.createElement(Text2, { bold: true, color: "yellow" }, "h")), /* @__PURE__ */ React2.createElement(Text2, null, "Toggle this help screen")), /* @__PURE__ */ React2.createElement(Box2, null, /* @__PURE__ */ React2.createElement(Box2, { width: 15 }, /* @__PURE__ */ React2.createElement(Text2, { bold: true, color: "yellow" }, "q"), /* @__PURE__ */ React2.createElement(Text2, null, " / "), /* @__PURE__ */ React2.createElement(Text2, { bold: true, color: "yellow" }, "Esc")), /* @__PURE__ */ React2.createElement(Text2, null, "Gracefully quit"))), /* @__PURE__ */ React2.createElement(Box2, { justifyContent: "center", marginTop: 1 }, /* @__PURE__ */ React2.createElement(Text2, { dimColor: true }, "Press any key to close"))));
};
var HelpModal_default = HelpModal;
// src/components/BranchCreationWizard.tsx
import React3, { useState as useState2, useEffect as useEffect2, useCallback } from "react";
import { Box as Box3, Text as Text3, useInput as useInput2 } from "ink";
var isValidGitBranchName = (name) => {
if (!name.trim()) {
return { valid: false, error: "Branch name cannot be empty" };
}
if (name.startsWith("-")) {
return { valid: false, error: "Branch name cannot start with '-'" };
}
if (name.endsWith(".lock")) {
return { valid: false, error: "Branch name cannot end with '.lock'" };
}
if (name.includes("..")) {
return { valid: false, error: "Branch name cannot contain '..'" };
}
if (name.includes("@{")) {
return { valid: false, error: "Branch name cannot contain '@{'" };
}
if (name.startsWith(".") || name.endsWith(".")) {
return { valid: false, error: "Branch name cannot start or end with '.'" };
}
if (name.includes("//")) {
return { valid: false, error: "Branch name cannot contain consecutive slashes" };
}
if (/[\x00-\x1f\x7f~^:?*\[\\]/.test(name)) {
return { valid: false, error: "Branch name contains invalid characters" };
}
return { valid: true };
};
var BranchCreationWizard = ({
repositories,
getBranchesForRepo,
getDefaultBranchForRepo,
createAndPushBranch,
onClose,
onComplete
}) => {
const [step, setStep] = useState2(repositories.length > 1 ? "SELECT_PROJECT" : "SELECT_BRANCH");
const [selectedProjectIndex, setSelectedProjectIndex] = useState2(repositories.length === 1 ? 0 : 0);
const [branches, setBranches] = useState2([]);
const [defaultBranch, setDefaultBranch] = useState2("");
const [selectedBranchIndex, setSelectedBranchIndex] = useState2(0);
const [branchName, setBranchName] = useState2("");
const [existingSuffix, setExistingSuffix] = useState2(null);
const [validationError, setValidationError] = useState2(null);
const [result, setResult] = useState2(null);
const [loading, setLoading] = useState2(false);
const loadBranches = useCallback(
async (repoIndex) => {
setLoading(true);
try {
const branchList = await getBranchesForRepo(repoIndex);
const defaultBr = getDefaultBranchForRepo(repoIndex);
setBranches(branchList);
setDefaultBranch(defaultBr);
const defaultIndex = branchList.indexOf(defaultBr);
setSelectedBranchIndex(defaultIndex >= 0 ? defaultIndex : 0);
} catch {
setBranches([]);
}
setLoading(false);
},
[getBranchesForRepo, getDefaultBranchForRepo]
);
const checkBranchExists = useCallback(
(name) => {
if (!name.trim()) {
setExistingSuffix(null);
setValidationError(null);
return;
}
const validation = isValidGitBranchName(name);
if (!validation.valid) {
setValidationError(validation.error ?? null);
setExistingSuffix(null);
return;
}
setValidationError(null);
let suffix = 0;
let testName = name;
while (branches.includes(testName)) {
suffix++;
testName = `${name}-${suffix}`;
}
setExistingSuffix(suffix > 0 ? suffix : null);
},
[branches]
);
useEffect2(() => {
if (step === "SELECT_BRANCH" && branches.length === 0 && !loading) {
loadBranches(selectedProjectIndex);
}
}, [step, selectedProjectIndex, branches.length, loading, loadBranches]);
useEffect2(() => {
if (step === "ENTER_NAME") {
checkBranchExists(branchName);
}
}, [branchName, step, checkBranchExists]);
const handleCreateBranch = async () => {
const trimmedName = branchName.trim();
if (!trimmedName) return;
const validation = isValidGitBranchName(trimmedName);
if (!validation.valid) {
setValidationError(validation.error ?? null);
return;
}
setStep("CREATING");
const baseBranch = branches[selectedBranchIndex];
const createResult = await createAndPushBranch(selectedProjectIndex, baseBranch, trimmedName);
setResult(createResult);
setStep("RESULT");
};
useInput2((input2, key) => {
if (step === "CREATING") return;
if (key.escape) {
if (step === "SELECT_PROJECT") {
onClose();
} else if (step === "SELECT_BRANCH") {
if (repositories.length > 1) {
setBranches([]);
setStep("SELECT_PROJECT");
} else {
onClose();
}
} else if (step === "ENTER_NAME") {
setBranchName("");
setExistingSuffix(null);
setStep("SELECT_BRANCH");
} else if (step === "RESULT") {
const context = result?.success ? {
repoIndex: selectedProjectIndex,
baseBranch: branches[selectedBranchIndex],
newBranch: result.finalName
} : void 0;
onComplete(result?.success ?? false, context);
}
return;
}
if (step === "SELECT_PROJECT") {
if (key.upArrow) {
setSelectedProjectIndex((prev) => Math.max(0, prev - 1));
} else if (key.downArrow) {
setSelectedProjectIndex((prev) => Math.min(repositories.length - 1, prev + 1));
} else if (key.return) {
loadBranches(selectedProjectIndex);
setStep("SELECT_BRANCH");
}
} else if (step === "SELECT_BRANCH") {
if (key.upArrow) {
setSelectedBranchIndex((prev) => Math.max(0, prev - 1));
} else if (key.downArrow) {
setSelectedBranchIndex((prev) => Math.min(branches.length - 1, prev + 1));
} else if (key.return && branches.length > 0) {
setStep("ENTER_NAME");
}
} else if (step === "ENTER_NAME") {
if (key.return && branchName.trim()) {
void handleCreateBranch();
} else if (key.backspace || key.delete) {
setBranchName((prev) => prev.slice(0, -1));
} else if (input2 && !key.ctrl && !key.meta) {
const validChar = /^[a-zA-Z0-9/_-]$/.test(input2);
if (validChar) {
setBranchName((prev) => prev + input2);
}
}
} else if (step === "RESULT") {
const context = result?.success ? {
repoIndex: selectedProjectIndex,
baseBranch: branches[selectedBranchIndex],
newBranch: result.finalName
} : void 0;
onComplete(result?.success ?? false, context);
}
});
const getStepNumber = () => {
if (repositories.length === 1) {
if (step === "SELECT_BRANCH") return 1;
if (step === "ENTER_NAME") return 2;
return 2;
}
if (step === "SELECT_PROJECT") return 1;
if (step === "SELECT_BRANCH") return 2;
if (step === "ENTER_NAME") return 3;
return 3;
};
const getTotalSteps = () => repositories.length === 1 ? 2 : 3;
const renderProjectSelection = () => /* @__PURE__ */ React3.createElement(Box3, { flexDirection: "column", gap: 1 }, /* @__PURE__ */ React3.createElement(Text3, null, "Select repository:"), /* @__PURE__ */ React3.createElement(Box3, { flexDirection: "column" }, repositories.map((repo, idx) => /* @__PURE__ */ React3.createElement(Box3, { key: repo.index }, /* @__PURE__ */ React3.createElement(Text3, { color: idx === selectedProjectIndex ? "cyan" : void 0 }, idx === selectedProjectIndex ? "> " : " ", repo.name)))));
const renderBranchSelection = () => {
if (loading) {
return /* @__PURE__ */ React3.createElement(Text3, { color: "yellow" }, "Loading branches...");
}
if (branches.length === 0) {
return /* @__PURE__ */ React3.createElement(Text3, { color: "red" }, "No branches found");
}
const visibleCount = 8;
const halfVisible = Math.floor(visibleCount / 2);
let startIdx = Math.max(0, selectedBranchIndex - halfVisible);
const endIdx = Math.min(branches.length, startIdx + visibleCount);
if (endIdx - startIdx < visibleCount) {
startIdx = Math.max(0, endIdx - visibleCount);
}
const visibleBranches = branches.slice(startIdx, endIdx);
return /* @__PURE__ */ React3.createElement(Box3, { flexDirection: "column", gap: 1 }, /* @__PURE__ */ React3.createElement(Text3, null, "Select base branch:"), /* @__PURE__ */ React3.createElement(Box3, { flexDirection: "column" }, startIdx > 0 && /* @__PURE__ */ React3.createElement(Text3, { dimColor: true }, " ..."), visibleBranches.map((branch, idx) => {
const actualIdx = startIdx + idx;
const isSelected = actualIdx === selectedBranchIndex;
const isDefault = branch === defaultBranch;
return /* @__PURE__ */ React3.createElement(Box3, { key: branch }, /* @__PURE__ */ React3.createElement(Text3, { color: isSelected ? "cyan" : void 0 }, isSelected ? "> " : " ", branch, isDefault && /* @__PURE__ */ React3.createElement(Text3, { color: "green" }, " (default)")));
}), endIdx < branches.length && /* @__PURE__ */ React3.createElement(Text3, { dimColor: true }, " ...")));
};
const renderNameInput = () => {
const baseBranch = branches[selectedBranchIndex] || "";
const finalName = existingSuffix !== null ? `${branchName}-${existingSuffix}` : branchName;
const endsWithSlash = branchName.endsWith("/");
return /* @__PURE__ */ React3.createElement(Box3, { flexDirection: "column", gap: 1 }, /* @__PURE__ */ React3.createElement(Text3, null, "Base branch: ", /* @__PURE__ */ React3.createElement(Text3, { color: "cyan" }, baseBranch)), /* @__PURE__ */ React3.createElement(Text3, null, "Enter new branch name:"), /* @__PURE__ */ React3.createElement(Box3, null, /* @__PURE__ */ React3.createElement(Text3, { color: "cyan" }, "> "), /* @__PURE__ */ React3.createElement(Text3, null, branchName), /* @__PURE__ */ React3.createElement(Text3, { color: "gray" }, "|")), validationError && /* @__PURE__ */ React3.createElement(Text3, { color: "red" }, validationError), !validationError && endsWithSlash && /* @__PURE__ */ React3.createElement(Text3, { color: "yellow", dimColor: true }, "Hint: consecutive slashes (//) are not allowed"), !validationError && !endsWithSlash && existingSuffix !== null && branchName && /* @__PURE__ */ React3.createElement(Text3, { color: "yellow" }, "Name exists, will create: ", /* @__PURE__ */ React3.createElement(Text3, { color: "cyan" }, finalName)));
};
const renderCreating = () => /* @__PURE__ */ React3.createElement(Box3, { flexDirection: "column", gap: 1 }, /* @__PURE__ */ React3.createElement(Text3, { color: "yellow" }, "Creating branch..."), /* @__PURE__ */ React3.createElement(Text3, { dimColor: true }, "Please wait while the branch is created and pushed to remote."));
const renderResult = () => {
if (!result) return null;
if (result.success) {
return /* @__PURE__ */ React3.createElement(Box3, { flexDirection: "column", gap: 1 }, /* @__PURE__ */ React3.createElement(Text3, { color: "green" }, "Branch created successfully!"), /* @__PURE__ */ React3.createElement(Text3, null, "Created: ", /* @__PURE__ */ React3.createElement(Text3, { color: "cyan" }, result.finalName)), /* @__PURE__ */ React3.createElement(Text3, null, "From: ", /* @__PURE__ */ React3.createElement(Text3, { color: "cyan" }, branches[selectedBranchIndex])), /* @__PURE__ */ React3.createElement(Text3, { dimColor: true }, "Syncing now to create the worktree..."));
}
return /* @__PURE__ */ React3.createElement(Box3, { flexDirection: "column", gap: 1 }, /* @__PURE__ */ React3.createElement(Text3, { color: "red" }, "Failed to create branch"), /* @__PURE__ */ React3.createElement(Text3, { color: "red" }, result.error));
};
const renderContent = () => {
switch (step) {
case "SELECT_PROJECT":
return renderProjectSelection();
case "SELECT_BRANCH":
return renderBranchSelection();
case "ENTER_NAME":
return renderNameInput();
case "CREATING":
return renderCreating();
case "RESULT":
return renderResult();
}
};
const renderFooter = () => {
if (step === "CREATING") return null;
if (step === "RESULT") {
return /* @__PURE__ */ React3.createElement(Text3, { dimColor: true }, "Press any key to continue");
}
if (step === "ENTER_NAME") {
return /* @__PURE__ */ React3.createElement(Text3, { dimColor: true }, "Enter to create \u2022 ESC to go back");
}
return /* @__PURE__ */ React3.createElement(Text3, { dimColor: true }, "\u2191/\u2193 to navigate \u2022 Enter to select \u2022 ESC to cancel");
};
return /* @__PURE__ */ React3.createElement(Box3, { flexDirection: "column", marginTop: 1, marginBottom: 1 }, /* @__PURE__ */ React3.createElement(Box3, { borderStyle: "round", borderColor: "green", paddingX: 2, paddingY: 1, flexDirection: "column", width: 60 }, /* @__PURE__ */ React3.createElement(Box3, { marginBottom: 1 }, /* @__PURE__ */ React3.createElement(Text3, { bold: true, color: "green" }, "\u{1F33F} Create New Branch", " ", step !== "CREATING" && step !== "RESULT" && /* @__PURE__ */ React3.createElement(Text3, { dimColor: true }, "(Step ", getStepNumber(), "/", getTotalSteps(), ")"))), repositories.length > 1 && step !== "SELECT_PROJECT" && step !== "CREATING" && step !== "RESULT" && /* @__PURE__ */ React3.createElement(Box3, { marginBottom: 1 }, /* @__PURE__ */ React3.createElement(Text3, null, "Repository: ", /* @__PURE__ */ React3.createElement(Text3, { color: "cyan" }, repositories[selectedProjectIndex].name))), renderContent(), /* @__PURE__ */ React3.createElement(Box3, { marginTop: 1 }, renderFooter())));
};
var BranchCreationWizard_default = BranchCreationWizard;
// src/components/OpenEditorWizard.tsx
import React4, { useState as useState3, useEffect as useEffect3, useMemo, useCallback as useCallback2, useRef } from "react";
import { Box as Box4, Text as Text4, useInput as useInput3 } from "ink";
var OpenEditorWizard = ({
repositories,
getWorktreesForRepo,
openEditorInWorktree,
onClose
}) => {
const [step, setStep] = useState3(repositories.length > 1 ? "SELECT_PROJECT" : "SELECT_WORKTREE");
const [selectedProjectIndex, setSelectedProjectIndex] = useState3(0);
const [projectFilter, setProjectFilter] = useState3("");
const selectedRepoIndexRef = useRef(repositories.length === 1 ? 0 : -1);
const [worktrees, setWorktrees] = useState3([]);
const [selectedWorktreeIndex, setSelectedWorktreeIndex] = useState3(0);
const [worktreeFilter, setWorktreeFilter] = useState3("");
const [loading, setLoading] = useState3(false);
const [error, setError] = useState3(null);
const filteredProjects = useMemo(() => {
if (!projectFilter) return repositories;
const lowerFilter = projectFilter.toLowerCase();
return repositories.filter((repo) => repo.name.toLowerCase().includes(lowerFilter));
}, [repositories, projectFilter]);
const filteredWorktrees = useMemo(() => {
if (!worktreeFilter) return worktrees;
const lowerFilter = worktreeFilter.toLowerCase();
return worktrees.filter((wt) => wt.branch.toLowerCase().includes(lowerFilter));
}, [worktrees, worktreeFilter]);
const loadWorktrees = useCallback2(
async (repoIndex) => {
setLoading(true);
try {
const wts = await getWorktreesForRepo(repoIndex);
setWorktrees(wts);
setSelectedWorktreeIndex(0);
} catch (err) {
setError(`Failed to load worktrees: ${err}`);
setStep("ERROR");
}
setLoading(false);
},
[getWorktreesForRepo]
);
useEffect3(() => {
if (step === "SELECT_WORKTREE" && worktrees.length === 0 && !loading && selectedRepoIndexRef.current >= 0) {
loadWorktrees(selectedRepoIndexRef.current);
}
}, [step, worktrees.length, loading, loadWorktrees]);
const handleOpenEditor = () => {
const worktree = filteredWorktrees[selectedWorktreeIndex];
if (!worktree) return;
setStep("OPENING");
const result = openEditorInWorktree(worktree.path);
if (result.success) {
onClose();
} else {
setError(result.error || "Failed to open editor");
setStep("ERROR");
}
};
useInput3((input2, key) => {
if (step === "OPENING") return;
if (key.escape) {
if (step === "SELECT_PROJECT") {
onClose();
} else if (step === "SELECT_WORKTREE") {
if (repositories.length > 1) {
setWorktrees([]);
setWorktreeFilter("");
selectedRepoIndexRef.current = -1;
setStep("SELECT_PROJECT");
} else {
onClose();
}
} else if (step === "ERROR") {
onClose();
}
return;
}
if (step === "SELECT_PROJECT") {
if (key.upArrow) {
setSelectedProjectIndex((prev) => Math.max(0, prev - 1));
} else if (key.downArrow) {
setSelectedProjectIndex((prev) => Math.min(filteredProjects.length - 1, prev + 1));
} else if (key.return && filteredProjects.length > 0) {
const selectedRepo = filteredProjects[selectedProjectIndex];
if (selectedRepo) {
selectedRepoIndexRef.current = selectedRepo.index;
setStep("SELECT_WORKTREE");
loadWorktrees(selectedRepo.index);
}
} else if (key.backspace || key.delete) {
setProjectFilter((prev) => prev.slice(0, -1));
setSelectedProjectIndex(0);
} else if (input2 && !key.ctrl && !key.meta) {
setProjectFilter((prev) => prev + input2);
setSelectedProjectIndex(0);
}
} else if (step === "SELECT_WORKTREE") {
if (key.upArrow) {
setSelectedWorktreeIndex((prev) => Math.max(0, prev - 1));
} else if (key.downArrow) {
setSelectedWorktreeIndex((prev) => Math.min(filteredWorktrees.length - 1, prev + 1));
} else if (key.return && filteredWorktrees.length > 0) {
handleOpenEditor();
} else if (key.backspace || key.delete) {
setWorktreeFilter((prev) => prev.slice(0, -1));
setSelectedWorktreeIndex(0);
} else if (input2 && !key.ctrl && !key.meta) {
setWorktreeFilter((prev) => prev + input2);
setSelectedWorktreeIndex(0);
}
} else if (step === "ERROR") {
onClose();
}
});
const getStepNumber = () => {
if (repositories.length === 1) {
return 1;
}
return step === "SELECT_PROJECT" ? 1 : 2;
};
const getTotalSteps = () => repositories.length === 1 ? 1 : 2;
const renderProjectSelection = () => {
const visibleCount = 8;
const halfVisible = Math.floor(visibleCount / 2);
let startIdx = Math.max(0, selectedProjectIndex - halfVisible);
const endIdx = Math.min(filteredProjects.length, startIdx + visibleCount);
if (endIdx - startIdx < visibleCount) {
startIdx = Math.max(0, endIdx - visibleCount);
}
const visibleProjects = filteredProjects.slice(startIdx, endIdx);
return /* @__PURE__ */ React4.createElement(Box4, { flexDirection: "column", gap: 1 }, /* @__PURE__ */ React4.createElement(Text4, null, "Select repository:"), /* @__PURE__ */ React4.createElement(Box4, null, /* @__PURE__ */ React4.createElement(Text4, null, "Filter: "), /* @__PURE__ */ React4.createElement(Text4, { color: "cyan" }, projectFilter || "_"), /* @__PURE__ */ React4.createElement(Text4, { dimColor: true }, " ", "(", filteredProjects.length, "/", repositories.length, " matches)")), /* @__PURE__ */ React4.createElement(Box4, { flexDirection: "column" }, filteredProjects.length === 0 ? /* @__PURE__ */ React4.createElement(Text4, { color: "yellow" }, "No matches") : /* @__PURE__ */ React4.createElement(React4.Fragment, null, startIdx > 0 && /* @__PURE__ */ React4.createElement(Text4, { dimColor: true }, " ..."), visibleProjects.map((repo, idx) => {
const actualIdx = startIdx + idx;
const isSelected = actualIdx === selectedProjectIndex;
return /* @__PURE__ */ React4.createElement(Box4, { key: repo.index }, /* @__PURE__ */ React4.createElement(Text4, { color: isSelected ? "cyan" : void 0 }, isSelected ? "> " : " ", repo.name));
}), endIdx < filteredProjects.length && /* @__PURE__ */ React4.createElement(Text4, { dimColor: true }, " ..."))));
};
const renderWorktreeSelection = () => {
if (loading) {
return /* @__PURE__ */ React4.createElement(Text4, { color: "yellow" }, "Loading worktrees...");
}
if (worktrees.length === 0) {
return /* @__PURE__ */ React4.createElement(Text4, { color: "red" }, "No worktrees found");
}
const visibleCount = 8;
const halfVisible = Math.floor(visibleCount / 2);
let startIdx = Math.max(0, selectedWorktreeIndex - halfVisible);
const endIdx = Math.min(filteredWorktrees.length, startIdx + visibleCount);
if (endIdx - startIdx < visibleCount) {
startIdx = Math.max(0, endIdx - visibleCount);
}
const visibleWorktrees = filteredWorktrees.slice(startIdx, endIdx);
return /* @__PURE__ */ React4.createElement(Box4, { flexDirection: "column", gap: 1 }, /* @__PURE__ */ React4.createElement(Text4, null, "Select worktree:"), /* @__PURE__ */ React4.createElement(Box4, null, /* @__PURE__ */ React4.createElement(Text4, null, "Filter: "), /* @__PURE__ */ React4.createElement(Text4, { color: "cyan" }, worktreeFilter || "_"), /* @__PURE__ */ React4.createElement(Text4, { dimColor: true }, " ", "(", filteredWorktrees.length, "/", worktrees.length, " matches)")), /* @__PURE__ */ React4.createElement(Box4, { flexDirection: "column" }, filteredWorktrees.length === 0 ? /* @__PURE__ */ React4.createElement(Text4, { color: "yellow" }, "No matches") : /* @__PURE__ */ React4.createElement(React4.Fragment, null, startIdx > 0 && /* @__PURE__ */ React4.createElement(Text4, { dimColor: true }, " ..."), visibleWorktrees.map((wt, idx) => {
const actualIdx = startIdx + idx;
const isSelected = actualIdx === selectedWorktreeIndex;
return /* @__PURE__ */ React4.createElement(Box4, { key: wt.path }, /* @__PURE__ */ React4.createElement(Text4, { color: isSelected ? "cyan" : void 0 }, isSelected ? "> " : " ", wt.branch));
}), endIdx < filteredWorktrees.length && /* @__PURE__ */ React4.createElement(Text4, { dimColor: true }, " ..."))));
};
const renderOpening = () => /* @__PURE__ */ React4.createElement(Box4, { flexDirection: "column", gap: 1 }, /* @__PURE__ */ React4.createElement(Text4, { color: "yellow" }, "Opening editor..."));
const renderError = () => /* @__PURE__ */ React4.createElement(Box4, { flexDirection: "column", gap: 1 }, /* @__PURE__ */ React4.createElement(Text4, { color: "red" }, "Error: ", error), /* @__PURE__ */ React4.createElement(Text4, { dimColor: true }, "Press any key to close"));
const renderContent = () => {
switch (step) {
case "SELECT_PROJECT":
return renderProjectSelection();
case "SELECT_WORKTREE":
return renderWorktreeSelection();
case "OPENING":
return renderOpening();
case "ERROR":
return renderError();
}
};
const renderFooter = () => {
if (step === "OPENING") return null;
if (step === "ERROR") return null;
return /* @__PURE__ */ React4.createElement(Text4, { dimColor: true }, "\u2191/\u2193 navigate \u2022 Type to filter \u2022 Enter to select \u2022 ESC to cancel");
};
return /* @__PURE__ */ React4.createElement(Box4, { flexDirection: "column", marginTop: 1, marginBottom: 1 }, /* @__PURE__ */ React4.createElement(Box4, { borderStyle: "round", borderColor: "blue", paddingX: 2, paddingY: 1, flexDirection: "column", width: 60 }, /* @__PURE__ */ React4.createElement(Box4, { marginBottom: 1 }, /* @__PURE__ */ React4.createElement(Text4, { bold: true, color: "blue" }, "\u{1F4C2} Open in Editor", " ", step !== "OPENING" && step !== "ERROR" && /* @__PURE__ */ React4.createElement(Text4, { dimColor: true }, "(Step ", getStepNumber(), "/", getTotalSteps(), ")"))), repositories.length > 1 && step === "SELECT_WORKTREE" && !loading && selectedRepoIndexRef.current >= 0 && /* @__PURE__ */ React4.createElement(Box4, { marginBottom: 1 }, /* @__PURE__ */ React4.createElement(Text4, null, "Repository: ", /* @__PURE__ */ React4.createElement(Text4, { color: "cyan" }, repositories.find((r) => r.index === selectedRepoIndexRef.current)?.name))), renderContent(), /* @__PURE__ */ React4.createElement(Box4, { marginTop: 1 }, renderFooter())));
};
var OpenEditorWizard_default = OpenEditorWizard;
// src/components/LogPanel.tsx
import React5, { useState as useState4, useEffect as useEffect4, useRef as useRef2 } from "react";
import { Box as Box5, Text as Text5, useInput as useInput4 } from "ink";
var LogPanel = ({ logs, height, isActive }) => {
const [scrollOffset, setScrollOffset] = useState4(0);
const [autoScroll, setAutoScroll] = useState4(true);
const [pendingG, setPendingG] = useState4(false);
const gTimeoutRef = useRef2(null);
const borderLines = 2;
const headerLine = 1;
const visibleLines = Math.max(1, height - borderLines - headerLine);
const maxOffset = Math.max(0, logs.length - visibleLines);
useEffect4(() => {
if (autoScroll) {
setScrollOffset(maxOffset);
}
}, [logs.length, maxOffset, autoScroll]);
useEffect4(() => {
return () => {
if (gTimeoutRef.current) {
clearTimeout(gTimeoutRef.current);
}
};
}, []);
useInput4(
(input2, key) => {
if (!isActive) return;
if (key.upArrow || input2 === "k") {
setScrollOffset((prev) => Math.max(0, prev - 1));
setAutoScroll(false);
setPendingG(false);
} else if (key.downArrow || input2 === "j") {
setScrollOffset((prev) => {
const newOffset = Math.min(maxOffset, prev + 1);
if (newOffset >= maxOffset) {
setAutoScroll(true);
}
return newOffset;
});
setPendingG(false);
} else if (key.pageUp) {
setScrollOffset((prev) => Math.max(0, prev - visibleLines));
setAutoScroll(false);
setPendingG(false);
} else if (key.pageDown) {
setScrollOffset((prev) => {
const newOffset = Math.min(maxOffset, prev + visibleLines);
if (newOffset >= maxOffset) {
setAutoScroll(true);
}
return newOffset;
});
setPendingG(false);
} else if (input2 === "g") {
if (pendingG) {
setScrollOffset(0);
setAutoScroll(false);
setPendingG(false);
if (gTimeoutRef.current) {
clearTimeout(gTimeoutRef.current);
gTimeoutRef.current = null;
}
} else {
setPendingG(true);
gTimeoutRef.current = setTimeout(() => {
setPendingG(false);
}, 500);
}
} else if (input2 === "G") {
setScrollOffset(maxOffset);
setAutoScroll(true);
setPendingG(false);
}
},
{ isActive }
);
const getLogColor = (level) => {
switch (level) {
case "error":
return "red";
case "warn":
return "yellow";
default:
return void 0;
}
};
const visibleLogs = logs.slice(scrollOffset, scrollOffset + visibleLines);
const hasMoreAbove = scrollOffset > 0;
const hasMoreBelow = scrollOffset + visibleLines < logs.length;
const aboveCount = scrollOffset;
const belowCount = logs.length - scrollOffset - visibleLines;
const emptyLines = Math.max(0, visibleLines - visibleLogs.length);
return /* @__PURE__ */ React5.createElement(Box5, { borderStyle: "single", flexDirection: "column", flexGrow: 1, paddingX: 1 }, /* @__PURE__ */ React5.createElement(Box5, { justifyContent: "space-between" }, /* @__PURE__ */ React5.createElement(Text5, { bold: true }, "\u{1F4CB} Logs ", logs.length > 0 && /* @__PURE__ */ React5.createElement(Text5, { dimColor: true }, "(", logs.length, " entries)")), isActive && /* @__PURE__ */ React5.createElement(Text5, { dimColor: true }, hasMoreAbove || hasMoreBelow ? "\u2191/\u2193 scroll" : "", " ", autoScroll ? "(auto)" : "")), hasMoreAbove && /* @__PURE__ */ React5.createElement(Text5, { dimColor: true }, "\u2191 ", aboveCount, " more above"), visibleLogs.map((log) => /* @__PURE__ */ React5.createElement(Text5, { key: log.id, color: getLogColor(log.level), wrap: "truncate" }, log.message)), Array.from({ length: emptyLines }).map((_, i) => /* @__PURE__ */ React5.createElement(Text5, { key: `empty-${i}` }, " ")), hasMoreBelow && /* @__PURE__ */ React5.createElement(Text5, { dimColor: true }, "\u2193 ",