From e35f9ad95e4f66ce05f2cba0f2e25163fe7ac803 Mon Sep 17 00:00:00 2001 From: Isaac Johnson Date: Sun, 31 May 2026 19:59:22 -0500 Subject: [PATCH] first --- .gitignore | 160 +++++++++ package-lock.json | 407 ++++++++++++++++++++++ package.json | 24 ++ src/api.ts | 172 ++++++++++ src/index.ts | 106 ++++++ src/tui.ts | 860 ++++++++++++++++++++++++++++++++++++++++++++++ src/types.ts | 77 +++++ tsconfig.json | 15 + 8 files changed, 1821 insertions(+) create mode 100644 .gitignore create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/api.ts create mode 100644 src/index.ts create mode 100644 src/tui.ts create mode 100644 src/types.ts create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..84964fe --- /dev/null +++ b/.gitignore @@ -0,0 +1,160 @@ +# Created by https://www.toptal.com/developers/gitignore/api/linux,node +# Edit at https://www.toptal.com/developers/gitignore?templates=linux,node + +### Linux ### +*~ + +# temporary files which can be created if a process still has a handle open of a deleted file +.fuse_hidden* + +# KDE directory preferences +.directory + +# Linux trash folder which might appear on any partition or disk +.Trash-* + +# .nfs files are created when an open file is removed but is still being accessed +.nfs* + +### Node ### +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp and cache directory +.temp + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +### Node Patch ### +# Serverless Webpack directories +.webpack/ + +# Optional stylelint cache + +# SvelteKit build / generate output +.svelte-kit + +# End of https://www.toptal.com/developers/gitignore/api/linux,node +.antigravitycli/ diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..274e3a8 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,407 @@ +{ + "name": "gitea-tui", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "gitea-tui", + "version": "1.0.0", + "dependencies": { + "axios": "^1.7.9", + "chalk": "^5.3.0", + "commander": "^12.1.0" + }, + "bin": { + "gitea-tui": "dist/index.js" + }, + "devDependencies": { + "@types/node": "^20.11.0", + "typescript": "^5.3.3" + } + }, + "node_modules/@types/node": { + "version": "20.19.41", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz", + "integrity": "sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz", + "integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..5b13309 --- /dev/null +++ b/package.json @@ -0,0 +1,24 @@ +{ + "name": "gitea-tui", + "version": "1.0.0", + "description": "Interactive TUI explorer for Gitea and Forgejo issues", + "type": "module", + "main": "dist/index.js", + "bin": { + "gitea-tui": "./dist/index.js" + }, + "scripts": { + "build": "tsc", + "start": "node dist/index.js", + "prepublishOnly": "npm run build" + }, + "dependencies": { + "axios": "^1.7.9", + "chalk": "^5.3.0", + "commander": "^12.1.0" + }, + "devDependencies": { + "@types/node": "^20.11.0", + "typescript": "^5.3.3" + } +} diff --git a/src/api.ts b/src/api.ts new file mode 100644 index 0000000..9112342 --- /dev/null +++ b/src/api.ts @@ -0,0 +1,172 @@ +import axios from 'axios'; +import { Config, Issue, Comment } from './types.js'; + +/** + * Normalizes the Gitea/Forgejo URL by stripping trailing slashes + * and ensuring it has a protocol (defaults to https). + */ +export function normalizeUrl(url: string): string { + let cleaned = url.trim(); + if (!cleaned) return ''; + if (!/^https?:\/\//i.test(cleaned)) { + cleaned = 'https://' + cleaned; + } + return cleaned.replace(/\/+$/, ''); +} + +/** + * Creates an Axios instance with standard Gitea/Forgejo headers. + */ +function createAxiosInstance(config: Config) { + const headers: Record = { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + }; + + if (config.token) { + headers['Authorization'] = `token ${config.token.trim()}`; + } + + return axios.create({ + baseURL: `${normalizeUrl(config.url)}/api/v1`, + headers, + timeout: 10000, + }); +} + +/** + * Tests the connection to the Gitea/Forgejo instance and validates that the repository exists. + * Returns the repository full name on success. + */ +export async function validateConnection(config: Config): Promise { + const client = createAxiosInstance(config); + try { + const response = await client.get(`/repos/${config.owner}/${config.repo}`); + return response.data.full_name || `${config.owner}/${config.repo}`; + } catch (error: any) { + if (error.response) { + if (error.response.status === 401) { + throw new Error('Unauthorized: Invalid API Token or authentication failed.'); + } + if (error.response.status === 404) { + throw new Error(`Repository "${config.owner}/${config.repo}" not found or is private.`); + } + throw new Error(`API Error: ${error.response.data?.message || error.message}`); + } + throw new Error(`Failed to connect to ${config.url}: ${error.message}`); + } +} + +/** + * Fetches a page of issues for a specific repository. + */ +export async function fetchIssues( + config: Config, + options: { + page: number; + limit: number; + state: 'open' | 'closed' | 'all'; + type: 'issues' | 'pulls' | 'all'; + q: string; + sortField: 'created' | 'updated' | 'comments'; + sortOrder: 'asc' | 'desc'; + } +): Promise<{ issues: Issue[]; totalCount: number }> { + const client = createAxiosInstance(config); + + // Map our internal sort fields to Gitea API sort parameters + let sort = 'newest'; + if (options.sortField === 'created') { + sort = options.sortOrder === 'asc' ? 'oldest' : 'newest'; + } else if (options.sortField === 'updated') { + sort = options.sortOrder === 'asc' ? 'leastupdate' : 'recentupdate'; + } else if (options.sortField === 'comments') { + sort = options.sortOrder === 'asc' ? 'leastcomment' : 'mostcomment'; + } + + const params: Record = { + page: options.page, + limit: options.limit, + state: options.state, + sort: sort, + }; + + // Filter by type: pulls or issues + if (options.type !== 'all') { + params.type = options.type; + } + + // Filter by search query if present + if (options.q.trim()) { + params.q = options.q.trim(); + } + + try { + const response = await client.get(`/repos/${config.owner}/${config.repo}/issues`, { params }); + + // Parse x-total-count header + const totalCountHeader = response.headers['x-total-count']; + const totalCount = totalCountHeader ? parseInt(totalCountHeader, 10) : response.data.length; + + const issues: Issue[] = response.data.map((item: any) => ({ + id: item.id, + number: item.number, + title: item.title, + state: item.state, + body: item.body || '', + user: { + id: item.user?.id || 0, + login: item.user?.login || 'unknown', + full_name: item.user?.full_name || '', + }, + created_at: item.created_at, + updated_at: item.updated_at, + comments: item.comments_count || item.comments || 0, + labels: (item.labels || []).map((l: any) => ({ + id: l.id, + name: l.name, + color: l.color, + })), + pull_request: item.pull_request, + })); + + return { issues, totalCount }; + } catch (error: any) { + if (error.response) { + throw new Error(`API Error: ${error.response.data?.message || error.message}`); + } + throw new Error(`Network Error: ${error.message}`); + } +} + +/** + * Fetches comments for a specific issue. + */ +export async function fetchIssueComments( + config: Config, + issueNumber: number +): Promise { + const client = createAxiosInstance(config); + try { + const response = await client.get(`/repos/${config.owner}/${config.repo}/issues/${issueNumber}/comments`); + + const comments: Comment[] = response.data.map((item: any) => ({ + id: item.id, + user: { + id: item.user?.id || 0, + login: item.user?.login || 'unknown', + full_name: item.user?.full_name || '', + }, + body: item.body || '', + created_at: item.created_at, + updated_at: item.updated_at, + })); + + return comments; + } catch (error: any) { + if (error.response) { + throw new Error(`Failed to fetch comments: ${error.response.data?.message || error.message}`); + } + throw new Error(`Network Error: ${error.message}`); + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..27b656e --- /dev/null +++ b/src/index.ts @@ -0,0 +1,106 @@ +#!/usr/bin/env node + +import { Command } from 'commander'; +import { AppState, Config } from './types.js'; +import { TuiEngine } from './tui.js'; +import { validateConnection, normalizeUrl } from './api.js'; +import chalk from 'chalk'; + +const program = new Command(); + +program + .name('gitea-tui') + .description('A premium CLI TUI Dashboard for exploring Gitea and Forgejo issues') + .version('1.0.0') + .option('-u, --url ', 'Forgejo/Gitea instance base URL (e.g. https://forgejo.freshbrewed.science)') + .option('-r, --repo ', 'Repository path (e.g. owner/repo)') + .option('-t, --token ', 'Personal Access Token (optional for public repositories)'); + +program.parse(process.argv); + +const options = program.opts(); + +async function bootstrap() { + // Initialize default state + const state: AppState = { + screen: 'setup', + config: { + url: '', + token: null, + owner: '', + repo: '', + }, + issues: [], + currentPage: 1, + issuesPerPage: 15, // matches standard screen sizes perfectly + totalIssuesCount: 0, + loading: false, + error: null, + selectedIssueIndex: 0, + searchQuery: '', + stateFilter: 'open', + typeFilter: 'all', + sortField: 'created', + sortOrder: 'desc', + selectedIssue: null, + selectedIssueComments: [], + commentsLoading: false, + detailScrollOffset: 0, + setupForm: { + url: options.url || 'https://forgejo.freshbrewed.science', + repo: options.repo || '', + token: options.token || '', + activeField: options.url ? (options.repo ? 'token' : 'repo') : 'url', + }, + }; + + // If parameters are provided, try direct connection first + if (options.url && options.repo) { + const repoParts = options.repo.split('/'); + if (repoParts.length === 2 && repoParts[0].trim() && repoParts[1].trim()) { + const normalized = normalizeUrl(options.url); + const config: Config = { + url: normalized, + token: options.token ? options.token.trim() : null, + owner: repoParts[0].trim(), + repo: repoParts[1].trim(), + }; + + console.log(chalk.cyan(`Connecting to Gitea/Forgejo instance at ${normalized}...`)); + + try { + await validateConnection(config); + // Valid connection! Go straight to list screen + state.config = config; + state.screen = 'list'; + } catch (err: any) { + // Validation failed, let's load setup form with the entered values and show error! + state.error = `Connection failed: ${err.message}`; + state.screen = 'setup'; + state.setupForm = { + url: options.url, + repo: options.repo, + token: options.token || '', + activeField: 'url', + }; + } + } else { + state.error = 'Invalid --repo option. Must be in owner/repo format.'; + state.screen = 'setup'; + } + } + + // Create and start the TUI engine + const engine = new TuiEngine(state); + + engine.start(() => { + // Perform cleanup and exit cleanly + console.log(chalk.bold.green('\nThank you for using Forgejo TUI Issue Explorer! Goodbye.')); + process.exit(0); + }); +} + +bootstrap().catch((err) => { + console.error(chalk.bold.red('Fatal Error on Bootstrap:'), err); + process.exit(1); +}); diff --git a/src/tui.ts b/src/tui.ts new file mode 100644 index 0000000..0970d4b --- /dev/null +++ b/src/tui.ts @@ -0,0 +1,860 @@ +import readline from 'readline'; +import chalk from 'chalk'; +import { AppState, Issue, Comment } from './types.js'; +import { fetchIssues, fetchIssueComments, validateConnection, normalizeUrl } from './api.js'; + +// Setup readline for stdin keypress events +readline.emitKeypressEvents(process.stdin); + +// Spinner chars for loading animations +const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; +let spinnerIndex = 0; +let spinnerInterval: NodeJS.Timeout | null = null; + +/** + * Word wraps text into lines of a maximum width. + */ +export function wordWrap(text: string, maxWidth: number): string[] { + if (!text) return []; + const lines: string[] = []; + const rawLines = text.split('\n'); + + for (const rawLine of rawLines) { + if (rawLine.trim() === '') { + lines.push(''); + continue; + } + + let currentLine = ''; + const words = rawLine.split(/\s+/); + + for (const word of words) { + if (!word) continue; + + if (currentLine.length + word.length + 1 <= maxWidth) { + currentLine += (currentLine ? ' ' : '') + word; + } else { + if (currentLine) { + lines.push(currentLine); + } + // If a single word is longer than maxWidth, force break it + let tempWord = word; + while (tempWord.length > maxWidth) { + lines.push(tempWord.substring(0, maxWidth)); + tempWord = tempWord.substring(maxWidth); + } + currentLine = tempWord; + } + } + if (currentLine) { + lines.push(currentLine); + } + } + + return lines; +} + +/** + * Formats an ISO date string into YYYY-MM-DD HH:MM. + */ +export function formatDate(dateStr: string): string { + try { + const d = new Date(dateStr); + if (isNaN(d.getTime())) return 'n/a'; + const yr = d.getFullYear(); + const mo = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + const hr = String(d.getHours()).padStart(2, '0'); + const min = String(d.getMinutes()).padStart(2, '0'); + return `${yr}-${mo}-${day} ${hr}:${min}`; + } catch { + return 'n/a'; + } +} + +/** + * Helper to truncate strings to a specific length. + */ +function truncate(str: string, length: number): string { + if (str.length <= length) return str; + return str.substring(0, length - 3) + '...'; +} + +/** + * TUI State Controller and Render Engine + */ +export class TuiEngine { + private state: AppState; + private onQuit: () => void = () => {}; + private activeSearchInput: boolean = false; + private searchInputBuffer: string = ''; + + constructor(initialState: AppState) { + this.state = initialState; + + // Set up resize handler + process.stdout.on('resize', () => { + this.render(); + }); + } + + /** + * Starts the TUI engine and registers keypress listeners. + */ + public start(onQuit: () => void) { + this.onQuit = onQuit; + + // Put terminal in raw mode + if (process.stdin.isTTY) { + process.stdin.setRawMode(true); + } + process.stdin.resume(); + process.stdin.on('keypress', this.handleKeypress.bind(this)); + + // Hide standard cursor + process.stdout.write('\x1B[?25l'); + + // Bootstrap if config exists + if (this.state.config.url && this.state.config.owner && this.state.config.repo) { + this.state.screen = 'list'; + this.loadIssues(); + } else { + this.render(); + } + } + + /** + * Stops the TUI engine, restores terminal state. + */ + public stop() { + this.stopSpinner(); + // Show standard cursor + process.stdout.write('\x1B[?25h'); + if (process.stdin.isTTY) { + process.stdin.setRawMode(false); + } + process.stdin.pause(); + this.onQuit(); + } + + private startSpinner() { + this.stopSpinner(); + spinnerInterval = setInterval(() => { + spinnerIndex = (spinnerIndex + 1) % SPINNER_FRAMES.length; + this.render(); + }, 80); + } + + private stopSpinner() { + if (spinnerInterval) { + clearInterval(spinnerInterval); + spinnerInterval = null; + } + } + + /** + * Fetches issues from the API client based on current state parameters. + */ + private async loadIssues() { + this.state.loading = true; + this.state.error = null; + this.startSpinner(); + + try { + const { issues, totalCount } = await fetchIssues(this.state.config, { + page: this.state.currentPage, + limit: this.state.issuesPerPage, + state: this.state.stateFilter, + type: this.state.typeFilter, + q: this.state.searchQuery, + sortField: this.state.sortField, + sortOrder: this.state.sortOrder, + }); + + this.state.issues = issues; + this.state.totalIssuesCount = totalCount; + + // Keep cursor bounds-checked + if (this.state.selectedIssueIndex >= issues.length) { + this.state.selectedIssueIndex = Math.max(0, issues.length - 1); + } + } catch (err: any) { + this.state.error = err.message; + this.state.issues = []; + this.state.totalIssuesCount = 0; + } finally { + this.state.loading = false; + this.stopSpinner(); + this.render(); + } + } + + /** + * Fetches comments for the selected issue. + */ + private async loadComments(issue: Issue) { + this.state.commentsLoading = true; + this.state.selectedIssueComments = []; + this.render(); + + try { + const comments = await fetchIssueComments(this.state.config, issue.number); + this.state.selectedIssueComments = comments; + } catch (err: any) { + // Gracefully show comment loading error + this.state.selectedIssueComments = [{ + id: -1, + user: { id: 0, login: 'system', full_name: 'System Error' }, + body: `Failed to load comments: ${err.message}`, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }]; + } finally { + this.state.commentsLoading = false; + this.render(); + } + } + + /** + * Keyboard input routers + */ + private async handleKeypress(str: string, key: any) { + // Standard Ctrl+C quit + if (key && key.ctrl && key.name === 'c') { + this.stop(); + return; + } + + if (this.state.screen === 'setup') { + this.handleSetupKeypress(str, key); + } else if (this.state.screen === 'list') { + if (this.activeSearchInput) { + this.handleSearchKeypress(str, key); + } else { + this.handleListKeypress(str, key); + } + } else if (this.state.screen === 'details') { + this.handleDetailsKeypress(str, key); + } + } + + /** + * Key handling for Setup form + */ + private async handleSetupKeypress(str: string, key: any) { + const form = this.state.setupForm; + const fields: Array<'url' | 'repo' | 'token'> = ['url', 'repo', 'token']; + const currentIdx = fields.indexOf(form.activeField); + + if (key && key.name === 'down') { + form.activeField = fields[(currentIdx + 1) % fields.length]; + this.render(); + return; + } + + if (key && key.name === 'up') { + form.activeField = fields[(currentIdx - 1 + fields.length) % fields.length]; + this.render(); + return; + } + + if (key && key.name === 'tab') { + form.activeField = fields[(currentIdx + 1) % fields.length]; + this.render(); + return; + } + + if ((key && key.name === 'return') || str === '\r' || str === '\n') { + // Submit form + if (!form.url.trim()) { + this.state.error = 'Instance URL is required.'; + this.render(); + return; + } + if (!form.repo.trim()) { + this.state.error = 'Repository path (owner/repo) is required.'; + this.render(); + return; + } + + const repoParts = form.repo.split('/'); + if (repoParts.length !== 2 || !repoParts[0].trim() || !repoParts[1].trim()) { + this.state.error = 'Repository must be in "owner/repo" format.'; + this.render(); + return; + } + + this.state.loading = true; + this.state.error = null; + this.render(); + + const normalizedUrl = normalizeUrl(form.url); + const testConfig = { + url: normalizedUrl, + token: form.token.trim() || null, + owner: repoParts[0].trim(), + repo: repoParts[1].trim(), + }; + + try { + await validateConnection(testConfig); + // Save config + this.state.config = testConfig; + this.state.screen = 'list'; + this.state.currentPage = 1; + this.state.selectedIssueIndex = 0; + this.loadIssues(); + } catch (err: any) { + this.state.error = err.message; + this.state.loading = false; + this.render(); + } + return; + } + + if ((key && key.name === 'escape') || str === '\u001b') { + this.stop(); + return; + } + + if (key && key.name === 'backspace') { + if (form.activeField === 'url') form.url = form.url.slice(0, -1); + if (form.activeField === 'repo') form.repo = form.repo.slice(0, -1); + if (form.activeField === 'token') form.token = form.token.slice(0, -1); + this.render(); + return; + } + + // Type character + if (str && !key.ctrl && !key.meta && str.length === 1 && str.charCodeAt(0) >= 32) { + if (form.activeField === 'url') form.url += str; + if (form.activeField === 'repo') form.repo += str; + if (form.activeField === 'token') form.token += str; + this.render(); + } + } + + /** + * Key handling for searching overlay + */ + private handleSearchKeypress(str: string, key: any) { + if ((key && key.name === 'escape') || str === '\u001b') { + this.activeSearchInput = false; + this.searchInputBuffer = ''; + process.stdout.write('\x1B[?25l'); // Hide cursor + this.render(); + return; + } + + if ((key && key.name === 'return') || str === '\r' || str === '\n') { + this.activeSearchInput = false; + this.state.searchQuery = this.searchInputBuffer; + this.state.currentPage = 1; + this.state.selectedIssueIndex = 0; + process.stdout.write('\x1B[?25l'); // Hide cursor + this.loadIssues(); + return; + } + + if (key && key.name === 'backspace') { + this.searchInputBuffer = this.searchInputBuffer.slice(0, -1); + this.render(); + return; + } + + if (str && !key.ctrl && !key.meta && str.length === 1 && str.charCodeAt(0) >= 32) { + this.searchInputBuffer += str; + this.render(); + } + } + + /** + * Key handling for Dashboard list + */ + private handleListKeypress(str: string, key: any) { + if (this.state.loading) return; + + if ((key && key.name === 'escape') || str === '\u001b') { + // If we entered config via CLI, quit. If we came from setup form, go back to setup! + if (this.state.setupForm.repo) { + this.state.screen = 'setup'; + this.state.error = null; + this.render(); + } else { + this.stop(); + } + return; + } + + if (key && key.name === 'up') { + if (this.state.selectedIssueIndex > 0) { + this.state.selectedIssueIndex--; + this.render(); + } + return; + } + + if (key && key.name === 'down') { + if (this.state.selectedIssueIndex < this.state.issues.length - 1) { + this.state.selectedIssueIndex++; + this.render(); + } + return; + } + + // Pagination: Right/PageDown -> Next, Left/PageUp -> Prev + if ((key && key.name === 'right') || (key && key.name === 'pagedown') || str === 'n') { + const maxPages = Math.ceil(this.state.totalIssuesCount / this.state.issuesPerPage); + if (this.state.currentPage < maxPages) { + this.state.currentPage++; + this.state.selectedIssueIndex = 0; + this.loadIssues(); + } + return; + } + + if ((key && key.name === 'left') || (key && key.name === 'pageup') || str === 'p') { + if (this.state.currentPage > 1) { + this.state.currentPage--; + this.state.selectedIssueIndex = 0; + this.loadIssues(); + } + return; + } + + if ((key && key.name === 'return') || str === '\r' || str === '\n') { + if (this.state.issues.length > 0) { + const issue = this.state.issues[this.state.selectedIssueIndex]; + this.state.selectedIssue = issue; + this.state.detailScrollOffset = 0; + this.state.screen = 'details'; + this.loadComments(issue); + } + return; + } + + // Toggle Filters & Sorting + if (str === '/') { + // Focus Search input + this.activeSearchInput = true; + this.searchInputBuffer = this.state.searchQuery; + this.render(); + return; + } + + if (str === 's' || str === 'S') { + // Cycle sorts: created -> updated -> comments + const fields: Array<'created' | 'updated' | 'comments'> = ['created', 'updated', 'comments']; + const currentSortIdx = fields.indexOf(this.state.sortField); + + // If desc, switch to asc. If asc, switch to next field desc! + if (this.state.sortOrder === 'desc') { + this.state.sortOrder = 'asc'; + } else { + this.state.sortOrder = 'desc'; + this.state.sortField = fields[(currentSortIdx + 1) % fields.length]; + } + this.state.currentPage = 1; + this.state.selectedIssueIndex = 0; + this.loadIssues(); + return; + } + + if (str === 'f' || str === 'F') { + // Cycle states: open -> closed -> all + const states: Array<'open' | 'closed' | 'all'> = ['open', 'closed', 'all']; + const currentIdx = states.indexOf(this.state.stateFilter); + this.state.stateFilter = states[(currentIdx + 1) % states.length]; + this.state.currentPage = 1; + this.state.selectedIssueIndex = 0; + this.loadIssues(); + return; + } + + if (str === 't' || str === 'T') { + // Cycle type: issues -> pulls -> all + const types: Array<'issues' | 'pulls' | 'all'> = ['issues', 'pulls', 'all']; + const currentIdx = types.indexOf(this.state.typeFilter); + this.state.typeFilter = types[(currentIdx + 1) % types.length]; + this.state.currentPage = 1; + this.state.selectedIssueIndex = 0; + this.loadIssues(); + return; + } + + if (str === 'r' || str === 'R') { + // Refresh + this.loadIssues(); + return; + } + + if (str === 'q' || str === 'Q') { + this.stop(); + } + } + + /** + * Key handling for detail screen scrolling + */ + private handleDetailsKeypress(str: string, key: any) { + if (key && key.name === 'up') { + if (this.state.detailScrollOffset > 0) { + this.state.detailScrollOffset--; + this.render(); + } + return; + } + + if (key && key.name === 'down') { + // We will bounds check this dynamically based on content length during render + this.state.detailScrollOffset++; + this.render(); + return; + } + + if (key && key.name === 'pageup') { + this.state.detailScrollOffset = Math.max(0, this.state.detailScrollOffset - 10); + this.render(); + return; + } + + if (key && key.name === 'pagedown') { + this.state.detailScrollOffset += 10; + this.render(); + return; + } + + if ((key && (key.name === 'escape' || key.name === 'backspace')) || str === '\u001b' || str === 'q' || str === 'Q') { + this.state.screen = 'list'; + this.state.selectedIssue = null; + this.state.selectedIssueComments = []; + this.render(); + } + } + + /** + * Renders the current screen to stdout + */ + public render() { + const cols = process.stdout.columns || 80; + const rows = process.stdout.rows || 24; + + // Clear terminal screen and reset cursor position + process.stdout.write('\x1B[2J\x1B[H'); + + if (this.state.screen === 'setup') { + this.renderSetupScreen(cols, rows); + } else if (this.state.screen === 'list') { + this.renderListScreen(cols, rows); + } else if (this.state.screen === 'details') { + this.renderDetailsScreen(cols, rows); + } + } + + /** + * Draw Setup Form + */ + private renderSetupScreen(cols: number, rows: number) { + const form = this.state.setupForm; + const width = Math.min(68, cols - 4); + const border = '│'; + + const lines: string[] = []; + lines.push(chalk.bold.hex('#4A90E2')('┌' + '─'.repeat(width - 2) + '┐')); + + const title = 'FORGEJO & GITEA TUI ISSUE EXPLORER'; + const titlePadding = ' '.repeat(Math.max(0, Math.floor((width - 2 - title.length) / 2))); + const titleLine = border + titlePadding + chalk.bold.white(title) + ' '.repeat(width - 2 - titlePadding.length - title.length) + border; + lines.push(titleLine); + lines.push(chalk.bold.hex('#4A90E2')('├' + '─'.repeat(width - 2) + '┤')); + + // Instructions + lines.push(border + ' '.repeat(width - 2) + border); + const desc = 'Please connect to a Forgejo/Gitea server to explore repository issues.'; + lines.push(border + ' ' + chalk.gray(desc.padEnd(width - 4)) + ' ' + border); + lines.push(border + ' '.repeat(width - 2) + border); + + // Form inputs + const renderField = (label: string, value: string, active: boolean, secret: boolean = false) => { + const fieldWidth = width - 8; + const displayVal = secret ? '*'.repeat(value.length) : value; + const cursorStr = active ? chalk.inverse(' ') : ''; + + const content = label.padEnd(16) + ': [ ' + + (active ? chalk.bold.cyan(displayVal) + cursorStr : chalk.white(displayVal)).padEnd(active ? displayVal.length + cursorStr.length + (fieldWidth - 19 - displayVal.length) : fieldWidth - 19) + + ' ]'; + + const coloredContent = active ? chalk.bold.white(content) : chalk.gray(content); + const activeMarker = active ? chalk.cyan('▶ ') : ' '; + return border + ' ' + activeMarker + coloredContent.padEnd(width - 6) + ' ' + border; + }; + + lines.push(renderField('Gitea URL', form.url, form.activeField === 'url')); + lines.push(border + ' '.repeat(width - 2) + border); + lines.push(renderField('Repository', form.repo, form.activeField === 'repo')); + lines.push(border + ' ' + chalk.gray('Format: owner/repo (e.g. gitea/tea)').padEnd(width - 5) + border); + lines.push(border + ' '.repeat(width - 2) + border); + lines.push(renderField('Access Token', form.token, form.activeField === 'token', true)); + lines.push(border + ' ' + chalk.gray('Optional. Required for private repos.').padEnd(width - 5) + border); + lines.push(border + ' '.repeat(width - 2) + border); + + // Error message + if (this.state.error) { + lines.push(chalk.bold.red('├' + '─'.repeat(width - 2) + '┤')); + const errorWrapped = wordWrap(this.state.error, width - 4); + for (const errLine of errorWrapped) { + lines.push(border + ' ' + chalk.bold.red(errLine.padEnd(width - 4)) + ' ' + border); + } + } + + lines.push(chalk.bold.hex('#4A90E2')('└' + '─'.repeat(width - 2) + '┘')); + + // Loading / Footer indicator + const spinner = this.state.loading ? chalk.cyan(SPINNER_FRAMES[spinnerIndex]) : ' '; + const prompt = this.state.loading ? ' Connecting to instance...' : ' [Tab/Arrows] Navigate [Enter] Connect [Esc] Quit'; + + // Draw centered on terminal + const vertPadding = Math.max(0, Math.floor((rows - lines.length - 2) / 2)); + process.stdout.write('\n'.repeat(vertPadding)); + for (const line of lines) { + console.log(' '.repeat(Math.max(0, Math.floor((cols - width) / 2))) + line); + } + + const footerText = ' '.repeat(Math.max(0, Math.floor((cols - width) / 2))) + spinner + chalk.gray(prompt); + console.log('\n' + footerText); + + // Cursor trick: if typing, place cursor at appropriate spot + if (!this.state.loading) { + // Find current active field offset row + let activeRowOffset = vertPadding + 6; // URL row + if (form.activeField === 'repo') activeRowOffset += 2; + if (form.activeField === 'token') activeRowOffset += 4; + + const activeValue = form.activeField === 'token' ? '*'.repeat(form.token.length) : form[form.activeField]; + const cursorCol = Math.max(0, Math.floor((cols - width) / 2)) + 23 + activeValue.length; + + process.stdout.write(`\x1B[${activeRowOffset + 1};${cursorCol}H\x1B[?25h`); // Show cursor + } + } + + /** + * Draw Issues List + */ + private renderListScreen(cols: number, rows: number) { + // Header Bar + const spinnerStr = this.state.loading ? chalk.bold.cyan(SPINNER_FRAMES[spinnerIndex]) + ' ' : ''; + const instanceName = normalizeUrl(this.state.config.url).replace(/^https?:\/\//, ''); + + let header = ` ${spinnerStr}${chalk.bold.hex('#4A90E2')('Forgejo Issue Explorer')} ─ ${chalk.bold.white(instanceName)} ─ repo: ${chalk.bold.cyan(`${this.state.config.owner}/${this.state.config.repo}`)}`; + if (this.state.totalIssuesCount > 0) { + const totalPages = Math.ceil(this.state.totalIssuesCount / this.state.issuesPerPage); + header += ` ─ Page ${chalk.bold.white(this.state.currentPage)} of ${chalk.bold.white(totalPages)} (${chalk.yellow(this.state.totalIssuesCount)} matching)`; + } else if (!this.state.loading) { + header += ' ─ (0 issues found)'; + } + + console.log(header); + console.log(chalk.bold.hex('#4A90E2')('┌' + '─'.repeat(cols - 2) + '┐')); + + // Column widths + const idWidth = 6; + const typeWidth = 5; + const stateWidth = 7; + const authorWidth = 14; + const createdWidth = 12; + const commentsWidth = 6; + // Title takes all remaining space + const titleWidth = Math.max(20, cols - idWidth - typeWidth - stateWidth - authorWidth - createdWidth - commentsWidth - 8); + + // Render Table Header + const padHeader = (title: string, w: number) => chalk.bold.white(title.padEnd(w)); + const borderCh = chalk.bold.hex('#4A90E2')('│'); + + console.log( + borderCh + ' ' + + padHeader('ID', idWidth) + borderCh + ' ' + + padHeader('Type', typeWidth) + borderCh + ' ' + + padHeader('State', stateWidth) + borderCh + ' ' + + padHeader('Title', titleWidth) + borderCh + ' ' + + padHeader('Author', authorWidth) + borderCh + ' ' + + padHeader('Created', createdWidth) + borderCh + ' ' + + padHeader('Coms', commentsWidth) + borderCh + ); + console.log(chalk.bold.hex('#4A90E2')('├' + '─'.repeat(cols - 2) + '┤')); + + // Table Content Height available + const tableHeight = rows - 7; // Header: 1, Box borders: 2, Table headers: 2, Bottom filter line: 1, Keyboard help: 1 + + if (this.state.issues.length === 0) { + const msg = this.state.loading ? 'Fetching issues from server...' : (this.state.error ? `Error: ${this.state.error}` : 'No issues found.'); + const paddingRows = Math.floor((tableHeight - 1) / 2); + for (let i = 0; i < paddingRows; i++) console.log(borderCh + ' '.repeat(cols - 2) + borderCh); + + const contentLine = (' '.repeat(Math.max(0, Math.floor((cols - 2 - msg.length) / 2))) + msg).padEnd(cols - 2); + console.log(borderCh + (this.state.error ? chalk.bold.red(contentLine) : chalk.gray(contentLine)) + borderCh); + + for (let i = 0; i < tableHeight - paddingRows - 1; i++) console.log(borderCh + ' '.repeat(cols - 2) + borderCh); + } else { + // Loop through issues on current page + for (let i = 0; i < tableHeight; i++) { + if (i < this.state.issues.length) { + const issue = this.state.issues[i]; + const isSelected = i === this.state.selectedIssueIndex; + + const idStr = `#${issue.number}`.padEnd(idWidth); + const typeStr = (issue.pull_request ? 'PR' : 'Issue').padEnd(typeWidth); + + let stateStr = issue.state.toUpperCase().padEnd(stateWidth); + if (issue.state === 'open') { + stateStr = issue.pull_request ? chalk.bold.magenta(stateStr) : chalk.bold.green(stateStr); + } else { + stateStr = chalk.bold.red(stateStr); + } + + const titleStr = truncate(issue.title, titleWidth).padEnd(titleWidth); + const authorStr = truncate(issue.user.login, authorWidth).padEnd(authorWidth); + const createdStr = formatDate(issue.created_at).substring(0, 10).padEnd(createdWidth); + const commentsStr = String(issue.comments).padEnd(commentsWidth); + + let rowText = + ' ' + idStr + borderCh + + ' ' + typeStr + borderCh + + ' ' + stateStr + borderCh + + ' ' + (issue.pull_request ? chalk.magenta(titleStr) : titleStr) + borderCh + + ' ' + authorStr + borderCh + + ' ' + createdStr + borderCh + + ' ' + commentsStr + borderCh; + + if (isSelected) { + rowText = chalk.bgHex('#2E4E7E').white.bold(rowText); + } + + console.log(borderCh + rowText); + } else { + // Fill empty space + console.log(borderCh + ' '.repeat(cols - 2) + borderCh); + } + } + } + + console.log(chalk.bold.hex('#4A90E2')('├' + '─'.repeat(cols - 2) + '┤')); + + // Render Filters line + let searchLabel = this.state.searchQuery ? chalk.yellow(`"${this.state.searchQuery}"`) : chalk.gray('none'); + if (this.activeSearchInput) { + searchLabel = chalk.inverse(this.searchInputBuffer + ' '); + } + const stateFilterLabel = chalk.bold(this.state.stateFilter.toUpperCase()); + const typeFilterLabel = chalk.bold(this.state.typeFilter.toUpperCase()); + const sortLabel = chalk.bold(`${this.state.sortField} (${this.state.sortOrder.toUpperCase()})`); + + const filtersText = ` Search: ${searchLabel} ─ State: ${stateFilterLabel} ─ Type: ${typeFilterLabel} ─ Sort: ${sortLabel}`; + console.log(borderCh + filtersText.padEnd(cols - 2) + borderCh); + console.log(chalk.bold.hex('#4A90E2')('└' + '─'.repeat(cols - 2) + '┘')); + + // Keyboard controls help line + const helpLine = chalk.gray(' [↑/↓] Navigate [Enter] View [/] Search [S] Sort [F] State [T] Type [N/P] Page [R] Reload [Esc] Back'); + process.stdout.write(helpLine); + + // If search active, position terminal cursor in search box + if (this.activeSearchInput) { + const searchCursorCol = 11 + this.searchInputBuffer.length; + process.stdout.write(`\x1B[${rows - 1};${searchCursorCol}H\x1B[?25h`); // Show cursor + } + } + + /** + * Draw Issue Detail scrollable view + */ + private renderDetailsScreen(cols: number, rows: number) { + const issue = this.state.selectedIssue; + if (!issue) return; + + // Header info line + const isPR = !!issue.pull_request; + const typeTag = isPR ? chalk.bold.magenta('[PR] ') : chalk.bold.green('[Issue] '); + console.log(` ${typeTag}#${issue.number} ─ ${chalk.bold.white(truncate(issue.title, cols - 16))}`); + console.log(chalk.bold.hex('#4A90E2')('┌' + '─'.repeat(cols - 2) + '┐')); + + // Metadata lines + const borderCh = chalk.bold.hex('#4A90E2')('│'); + + let stateLabel = issue.state.toUpperCase(); + if (issue.state === 'open') { + stateLabel = isPR ? chalk.bold.magenta(stateLabel) : chalk.bold.green(stateLabel); + } else { + stateLabel = chalk.bold.red(stateLabel); + } + + const labels = issue.labels.map(l => chalk.bgHex('#' + l.color).black(` ${l.name} `)).join(' '); + + console.log(borderCh + ` State: ${stateLabel} Author: ${chalk.cyan(issue.user.login)} Created: ${formatDate(issue.created_at)} Updated: ${formatDate(issue.updated_at)}`.padEnd(cols - 2) + borderCh); + if (labels) { + console.log(borderCh + ` Labels: ${labels}`.padEnd(cols - 2 + labels.length - issue.labels.map(l=>l.name.length + 10).reduce((a,b)=>a+b, 0)) + borderCh); // offset for raw ANSI chars + } + console.log(chalk.bold.hex('#4A90E2')('├' + '─'.repeat(cols - 2) + '┤')); + + // Gather and format all detail text (Body + Comments) + const contentLines: string[] = []; + + // 1. Render Body + contentLines.push(chalk.bold.yellow('--- DESCRIPTION ---')); + if (issue.body.trim()) { + const wrappedBody = wordWrap(issue.body, cols - 6); + contentLines.push(...wrappedBody); + } else { + contentLines.push(chalk.italic.gray('No description provided.')); + } + contentLines.push(''); + + // 2. Render Comments Section + contentLines.push(chalk.bold.yellow(`--- COMMENTS (${issue.comments}) ---`)); + + if (this.state.commentsLoading) { + contentLines.push(chalk.cyan(' Loading comments...')); + } else if (this.state.selectedIssueComments.length === 0) { + contentLines.push(chalk.italic.gray(' No comments.')); + } else { + for (const comment of this.state.selectedIssueComments) { + contentLines.push(chalk.bold.cyan(`▶ ${comment.user.login}`) + chalk.gray(` at ${formatDate(comment.created_at)}`)); + const wrappedComment = wordWrap(comment.body, cols - 8); + for (const cLine of wrappedComment) { + contentLines.push(' ' + cLine); + } + contentLines.push(''); // blank line between comments + } + } + + // Paginate/Scroll calculations + const displayHeight = rows - 6; // Header: 1, borders: 2, meta: 2, help: 1 + const maxScroll = Math.max(0, contentLines.length - displayHeight); + + // Bounds check scroll offset + if (this.state.detailScrollOffset > maxScroll) { + this.state.detailScrollOffset = maxScroll; + } + + // Print visible window of content + for (let r = 0; r < displayHeight; r++) { + const lineIndex = r + this.state.detailScrollOffset; + if (lineIndex < contentLines.length) { + const line = contentLines[lineIndex]; + console.log(borderCh + ' ' + line.padEnd(cols - 4) + ' ' + borderCh); + } else { + console.log(borderCh + ' '.repeat(cols - 2) + borderCh); + } + } + + console.log(chalk.bold.hex('#4A90E2')('└' + '─'.repeat(cols - 2) + '┘')); + + // Footer Scroll percentage + let scrollHelp = ' Scroll: [↑/↓] / [PgUp/PgDn]'; + if (maxScroll > 0) { + const pct = Math.round((this.state.detailScrollOffset / maxScroll) * 100); + scrollHelp += chalk.yellow(` (${pct}%)`); + } + + const helpLine = chalk.gray(` [Esc/Backspace] Back to List ${scrollHelp}`); + process.stdout.write(helpLine); + } +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..d9b0405 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,77 @@ +export interface Config { + url: string; + token: string | null; + owner: string; + repo: string; +} + +export interface User { + id: number; + login: string; + full_name: string; +} + +export interface Label { + id: number; + name: string; + color: string; +} + +export interface Issue { + id: number; + number: number; + title: string; + state: 'open' | 'closed'; + body: string; + user: User; + created_at: string; + updated_at: string; + comments: number; + labels: Label[]; + pull_request?: any | null; +} + +export interface Comment { + id: number; + user: User; + body: string; + created_at: string; + updated_at: string; +} + +export interface SetupForm { + url: string; + token: string; + repo: string; + activeField: 'url' | 'token' | 'repo'; +} + +export type ScreenType = 'setup' | 'list' | 'details'; + +export interface AppState { + screen: ScreenType; + config: Config; + + // List Screen State + issues: Issue[]; + currentPage: number; + issuesPerPage: number; + totalIssuesCount: number; // calculated or fetched + loading: boolean; + error: string | null; + selectedIssueIndex: number; // cursor in list + searchQuery: string; + stateFilter: 'open' | 'closed' | 'all'; + typeFilter: 'issues' | 'pulls' | 'all'; + sortField: 'created' | 'updated' | 'comments'; + sortOrder: 'asc' | 'desc'; + + // Detail Screen State + selectedIssue: Issue | null; + selectedIssueComments: Comment[]; + commentsLoading: boolean; + detailScrollOffset: number; + + // Setup Form State + setupForm: SetupForm; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..efa6173 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"] +}