first
This commit is contained in:
commit
e35f9ad95e
|
|
@ -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/
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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<string, string> = {
|
||||
'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<string> {
|
||||
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<string, any> = {
|
||||
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<Comment[]> {
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 <url>', 'Forgejo/Gitea instance base URL (e.g. https://forgejo.freshbrewed.science)')
|
||||
.option('-r, --repo <owner/repo>', 'Repository path (e.g. owner/repo)')
|
||||
.option('-t, --token <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);
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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/**/*"]
|
||||
}
|
||||
Loading…
Reference in New Issue