@muhammedaksam/easiarr 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +173 -0
- package/package.json +72 -0
- package/src/VersionInfo.ts +12 -0
- package/src/api/arr-api.ts +198 -0
- package/src/api/index.ts +1 -0
- package/src/apps/categories.ts +14 -0
- package/src/apps/index.ts +2 -0
- package/src/apps/registry.ts +868 -0
- package/src/compose/generator.ts +234 -0
- package/src/compose/index.ts +2 -0
- package/src/compose/templates.ts +68 -0
- package/src/config/defaults.ts +37 -0
- package/src/config/index.ts +3 -0
- package/src/config/manager.ts +109 -0
- package/src/config/schema.ts +191 -0
- package/src/docker/client.ts +129 -0
- package/src/docker/index.ts +1 -0
- package/src/index.ts +24 -0
- package/src/structure/manager.ts +86 -0
- package/src/ui/App.ts +95 -0
- package/src/ui/components/ApplicationSelector.ts +256 -0
- package/src/ui/components/FileEditor.ts +91 -0
- package/src/ui/components/PageLayout.ts +104 -0
- package/src/ui/index.ts +1 -0
- package/src/ui/screens/AdvancedSettings.ts +177 -0
- package/src/ui/screens/ApiKeyViewer.ts +223 -0
- package/src/ui/screens/AppConfigurator.ts +549 -0
- package/src/ui/screens/AppManager.ts +271 -0
- package/src/ui/screens/ContainerControl.ts +142 -0
- package/src/ui/screens/MainMenu.ts +161 -0
- package/src/ui/screens/QuickSetup.ts +1110 -0
- package/src/ui/screens/SecretsEditor.ts +256 -0
- package/src/ui/screens/index.ts +4 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Docker Client
|
|
3
|
+
* Wrapper for docker compose commands using Bun.$
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { $ } from "bun"
|
|
7
|
+
import { getComposePath } from "../config/manager"
|
|
8
|
+
|
|
9
|
+
export interface ContainerStatus {
|
|
10
|
+
name: string
|
|
11
|
+
status: "running" | "stopped" | "not_found"
|
|
12
|
+
ports?: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function composeUp(): Promise<{
|
|
16
|
+
success: boolean
|
|
17
|
+
output: string
|
|
18
|
+
}> {
|
|
19
|
+
try {
|
|
20
|
+
const composePath = getComposePath()
|
|
21
|
+
const result = await $`docker compose -f ${composePath} up -d`.text()
|
|
22
|
+
return { success: true, output: result }
|
|
23
|
+
} catch (error) {
|
|
24
|
+
return { success: false, output: String(error) }
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function composeDown(): Promise<{
|
|
29
|
+
success: boolean
|
|
30
|
+
output: string
|
|
31
|
+
}> {
|
|
32
|
+
try {
|
|
33
|
+
const composePath = getComposePath()
|
|
34
|
+
const result = await $`docker compose -f ${composePath} down`.text()
|
|
35
|
+
return { success: true, output: result }
|
|
36
|
+
} catch (error) {
|
|
37
|
+
return { success: false, output: String(error) }
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function composeRestart(service?: string): Promise<{ success: boolean; output: string }> {
|
|
42
|
+
try {
|
|
43
|
+
const composePath = getComposePath()
|
|
44
|
+
const result = service
|
|
45
|
+
? await $`docker compose -f ${composePath} restart ${service}`.text()
|
|
46
|
+
: await $`docker compose -f ${composePath} restart`.text()
|
|
47
|
+
return { success: true, output: result }
|
|
48
|
+
} catch (error) {
|
|
49
|
+
return { success: false, output: String(error) }
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function composeStop(service?: string): Promise<{ success: boolean; output: string }> {
|
|
54
|
+
try {
|
|
55
|
+
const composePath = getComposePath()
|
|
56
|
+
const result = service
|
|
57
|
+
? await $`docker compose -f ${composePath} stop ${service}`.text()
|
|
58
|
+
: await $`docker compose -f ${composePath} stop`.text()
|
|
59
|
+
return { success: true, output: result }
|
|
60
|
+
} catch (error) {
|
|
61
|
+
return { success: false, output: String(error) }
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function composeStart(service?: string): Promise<{ success: boolean; output: string }> {
|
|
66
|
+
try {
|
|
67
|
+
const composePath = getComposePath()
|
|
68
|
+
const result = service
|
|
69
|
+
? await $`docker compose -f ${composePath} start ${service}`.text()
|
|
70
|
+
: await $`docker compose -f ${composePath} start`.text()
|
|
71
|
+
return { success: true, output: result }
|
|
72
|
+
} catch (error) {
|
|
73
|
+
return { success: false, output: String(error) }
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function getContainerStatuses(): Promise<ContainerStatus[]> {
|
|
78
|
+
try {
|
|
79
|
+
const composePath = getComposePath()
|
|
80
|
+
const result = await $`docker compose -f ${composePath} ps --format json`.text()
|
|
81
|
+
|
|
82
|
+
if (!result.trim()) {
|
|
83
|
+
return []
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Parse JSON lines output
|
|
87
|
+
const lines = result.trim().split("\n")
|
|
88
|
+
const statuses: ContainerStatus[] = []
|
|
89
|
+
|
|
90
|
+
for (const line of lines) {
|
|
91
|
+
try {
|
|
92
|
+
const container = JSON.parse(line)
|
|
93
|
+
statuses.push({
|
|
94
|
+
name: container.Name || container.Service,
|
|
95
|
+
status: container.State === "running" ? "running" : "stopped",
|
|
96
|
+
ports: container.Ports,
|
|
97
|
+
})
|
|
98
|
+
} catch {
|
|
99
|
+
// Skip malformed lines
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return statuses
|
|
104
|
+
} catch {
|
|
105
|
+
return []
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export async function pullImages(): Promise<{
|
|
110
|
+
success: boolean
|
|
111
|
+
output: string
|
|
112
|
+
}> {
|
|
113
|
+
try {
|
|
114
|
+
const composePath = getComposePath()
|
|
115
|
+
const result = await $`docker compose -f ${composePath} pull`.text()
|
|
116
|
+
return { success: true, output: result }
|
|
117
|
+
} catch (error) {
|
|
118
|
+
return { success: false, output: String(error) }
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function isDockerAvailable(): Promise<boolean> {
|
|
123
|
+
try {
|
|
124
|
+
await $`docker --version`.quiet()
|
|
125
|
+
return true
|
|
126
|
+
} catch {
|
|
127
|
+
return false
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./client"
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Easiarr Entry Point
|
|
4
|
+
* TUI tool for generating docker-compose files for the *arr ecosystem
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { createCliRenderer } from "@opentui/core"
|
|
8
|
+
import { App } from "./ui/App"
|
|
9
|
+
|
|
10
|
+
async function main() {
|
|
11
|
+
const renderer = await createCliRenderer({
|
|
12
|
+
consoleOptions: {
|
|
13
|
+
startInDebugMode: false,
|
|
14
|
+
},
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
const app = new App(renderer)
|
|
18
|
+
await app.start()
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
main().catch((error) => {
|
|
22
|
+
console.error("Fatal error:", error)
|
|
23
|
+
process.exit(1)
|
|
24
|
+
})
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { mkdir } from "node:fs/promises"
|
|
2
|
+
import { join } from "node:path"
|
|
3
|
+
import type { EasiarrConfig, AppId } from "../config/schema"
|
|
4
|
+
|
|
5
|
+
const BASE_DIRS = ["torrents", "usenet", "media"]
|
|
6
|
+
|
|
7
|
+
// Map apps to their content type folders
|
|
8
|
+
const CONTENT_TYPE_MAP: Partial<Record<AppId, string>> = {
|
|
9
|
+
radarr: "movies",
|
|
10
|
+
sonarr: "tv",
|
|
11
|
+
lidarr: "music",
|
|
12
|
+
readarr: "books",
|
|
13
|
+
mylar3: "comics",
|
|
14
|
+
whisparr: "adult",
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function ensureDirectoryStructure(config: EasiarrConfig): Promise<void> {
|
|
18
|
+
try {
|
|
19
|
+
const dataRoot = join(config.rootDir, "data")
|
|
20
|
+
|
|
21
|
+
// Create base data directory
|
|
22
|
+
await mkdir(dataRoot, { recursive: true })
|
|
23
|
+
|
|
24
|
+
// 1. Create Base Directories (torrents, usenet, media)
|
|
25
|
+
for (const dir of BASE_DIRS) {
|
|
26
|
+
await mkdir(join(dataRoot, dir), { recursive: true })
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// 2. Create Content Subdirectories based on enabled apps
|
|
30
|
+
const enabledApps = new Set(config.apps.filter((a) => a.enabled).map((a) => a.id))
|
|
31
|
+
|
|
32
|
+
for (const [appId, contentType] of Object.entries(CONTENT_TYPE_MAP)) {
|
|
33
|
+
if (enabledApps.has(appId as AppId)) {
|
|
34
|
+
// Create this content type in ALL base dirs to follow TRaSH standard
|
|
35
|
+
// (e.g. data/torrents/movies, data/usenet/movies, data/media/movies)
|
|
36
|
+
for (const base of BASE_DIRS) {
|
|
37
|
+
await mkdir(join(dataRoot, base, contentType), { recursive: true })
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// 3. Special cases & MediaStack Extras
|
|
43
|
+
|
|
44
|
+
// Always create 'photos' in media (Personal photos)
|
|
45
|
+
await mkdir(join(dataRoot, "media", "photos"), { recursive: true })
|
|
46
|
+
|
|
47
|
+
// Always create 'console' and 'software' in torrents/usenet (Manual DL categories)
|
|
48
|
+
for (const base of ["torrents", "usenet"]) {
|
|
49
|
+
await mkdir(join(dataRoot, base, "console"), { recursive: true })
|
|
50
|
+
await mkdir(join(dataRoot, base, "software"), { recursive: true })
|
|
51
|
+
// Create 'watch' folder for manual .torrent/.nzb drops
|
|
52
|
+
await mkdir(join(dataRoot, base, "watch"), { recursive: true })
|
|
53
|
+
|
|
54
|
+
// 'complete' and 'incomplete' default folders
|
|
55
|
+
await mkdir(join(dataRoot, base, "complete"), { recursive: true })
|
|
56
|
+
await mkdir(join(dataRoot, base, "incomplete"), { recursive: true })
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (enabledApps.has("prowlarr")) {
|
|
60
|
+
// Prowlarr uncategorized downloads
|
|
61
|
+
await mkdir(join(dataRoot, "torrents", "prowlarr"), { recursive: true })
|
|
62
|
+
await mkdir(join(dataRoot, "usenet", "prowlarr"), { recursive: true })
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (enabledApps.has("filebot")) {
|
|
66
|
+
await mkdir(join(dataRoot, "filebot", "input"), { recursive: true })
|
|
67
|
+
await mkdir(join(dataRoot, "filebot", "output"), { recursive: true })
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (enabledApps.has("audiobookshelf")) {
|
|
71
|
+
// Audiobookshelf usually resides in media/audiobooks and media/podcasts
|
|
72
|
+
await mkdir(join(dataRoot, "media", "audiobooks"), { recursive: true })
|
|
73
|
+
await mkdir(join(dataRoot, "media", "podcasts"), { recursive: true })
|
|
74
|
+
}
|
|
75
|
+
} catch (error: unknown) {
|
|
76
|
+
const err = error as { code?: string; message: string }
|
|
77
|
+
if (err.code === "EACCES") {
|
|
78
|
+
console.error(`\n⚠ Warning: Permission denied when creating directories at ${config.rootDir}`)
|
|
79
|
+
console.error(" Please create the folders manually or check directory permissions.")
|
|
80
|
+
console.error(` Error: ${err.message}\n`)
|
|
81
|
+
// Do not throw, allow setup to continue
|
|
82
|
+
} else {
|
|
83
|
+
throw error
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
package/src/ui/App.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Main Application Controller
|
|
3
|
+
* Manages navigation between screens
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { KeyEvent, CliRenderer } from "@opentui/core"
|
|
7
|
+
import { BoxRenderable } from "@opentui/core"
|
|
8
|
+
import { loadConfig, configExists, saveConfig } from "../config"
|
|
9
|
+
import type { EasiarrConfig } from "../config/schema"
|
|
10
|
+
import { MainMenu } from "./screens/MainMenu"
|
|
11
|
+
import { QuickSetup } from "./screens/QuickSetup"
|
|
12
|
+
import { AppManager } from "./screens/AppManager"
|
|
13
|
+
import { ContainerControl } from "./screens/ContainerControl"
|
|
14
|
+
import { AdvancedSettings } from "./screens/AdvancedSettings"
|
|
15
|
+
|
|
16
|
+
export type Screen = "main" | "quickSetup" | "appManager" | "containerControl" | "advancedSettings"
|
|
17
|
+
|
|
18
|
+
export class App {
|
|
19
|
+
private renderer: CliRenderer
|
|
20
|
+
private config: EasiarrConfig | null = null
|
|
21
|
+
private currentScreen: Screen = "main"
|
|
22
|
+
private screenContainer: BoxRenderable
|
|
23
|
+
|
|
24
|
+
constructor(renderer: CliRenderer) {
|
|
25
|
+
this.renderer = renderer
|
|
26
|
+
this.screenContainer = new BoxRenderable(renderer, {
|
|
27
|
+
id: "screen-container",
|
|
28
|
+
width: "100%",
|
|
29
|
+
height: "100%",
|
|
30
|
+
})
|
|
31
|
+
renderer.root.add(this.screenContainer)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async start(): Promise<void> {
|
|
35
|
+
// Load existing config or show quick setup
|
|
36
|
+
if (await configExists()) {
|
|
37
|
+
this.config = await loadConfig()
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (!this.config) {
|
|
41
|
+
this.navigateTo("quickSetup")
|
|
42
|
+
} else {
|
|
43
|
+
this.navigateTo("main")
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Handle exit
|
|
47
|
+
this.renderer.keyInput.on("keypress", (key: KeyEvent) => {
|
|
48
|
+
if (key.ctrl && key.name === "c") {
|
|
49
|
+
process.exit(0)
|
|
50
|
+
}
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
navigateTo(screen: Screen): void {
|
|
55
|
+
this.currentScreen = screen
|
|
56
|
+
|
|
57
|
+
// Clear all children from container
|
|
58
|
+
const children = this.screenContainer.getChildren()
|
|
59
|
+
for (const child of children) {
|
|
60
|
+
this.screenContainer.remove(child.id)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
switch (screen) {
|
|
64
|
+
case "main":
|
|
65
|
+
new MainMenu(this.renderer, this.screenContainer, this, this.config!)
|
|
66
|
+
break
|
|
67
|
+
case "quickSetup":
|
|
68
|
+
new QuickSetup(this.renderer, this.screenContainer, this)
|
|
69
|
+
break
|
|
70
|
+
case "appManager":
|
|
71
|
+
new AppManager(this.renderer, this.screenContainer, this, this.config!)
|
|
72
|
+
break
|
|
73
|
+
case "containerControl":
|
|
74
|
+
new ContainerControl(this.renderer, this.screenContainer, this, this.config!)
|
|
75
|
+
break
|
|
76
|
+
case "advancedSettings":
|
|
77
|
+
new AdvancedSettings(this.renderer, this.screenContainer, this, this.config!)
|
|
78
|
+
break
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async saveAndReload(config: EasiarrConfig): Promise<void> {
|
|
83
|
+
await saveConfig(config)
|
|
84
|
+
this.config = config
|
|
85
|
+
this.navigateTo("main")
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
setConfig(config: EasiarrConfig): void {
|
|
89
|
+
this.config = config
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
getConfig(): EasiarrConfig | null {
|
|
93
|
+
return this.config
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BoxRenderable,
|
|
3
|
+
BoxOptions,
|
|
4
|
+
CliRenderer,
|
|
5
|
+
RenderContext,
|
|
6
|
+
TabSelectRenderable,
|
|
7
|
+
SelectRenderable,
|
|
8
|
+
TextRenderable,
|
|
9
|
+
SelectRenderableEvents,
|
|
10
|
+
KeyEvent,
|
|
11
|
+
} from "@opentui/core"
|
|
12
|
+
import { AppId } from "../../config/schema"
|
|
13
|
+
import { CATEGORY_ORDER } from "../../apps/categories"
|
|
14
|
+
import { getAppsByCategory } from "../../apps"
|
|
15
|
+
|
|
16
|
+
export interface ApplicationSelectorOptions extends BoxOptions {
|
|
17
|
+
selectedApps: Set<AppId>
|
|
18
|
+
onToggle?: (appId: AppId, enabled: boolean) => void
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class ApplicationSelector extends BoxRenderable {
|
|
22
|
+
private selectedApps: Set<AppId>
|
|
23
|
+
private onToggle?: (appId: AppId, enabled: boolean) => void
|
|
24
|
+
private currentCategoryIndex: number = 0
|
|
25
|
+
private tabs: TabSelectRenderable
|
|
26
|
+
private appList: SelectRenderable
|
|
27
|
+
private warningBox: BoxRenderable
|
|
28
|
+
private _renderer: CliRenderer | RenderContext
|
|
29
|
+
|
|
30
|
+
// Track internal focus state
|
|
31
|
+
private activeComponent: "tabs" | "list" = "tabs"
|
|
32
|
+
|
|
33
|
+
constructor(renderer: CliRenderer | RenderContext, options: ApplicationSelectorOptions) {
|
|
34
|
+
super(renderer, {
|
|
35
|
+
...options,
|
|
36
|
+
flexDirection: "column",
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
this._renderer = renderer
|
|
40
|
+
this.selectedApps = options.selectedApps
|
|
41
|
+
this.onToggle = options.onToggle
|
|
42
|
+
|
|
43
|
+
// 1. Conflict Warnings Area (Dynamic)
|
|
44
|
+
this.warningBox = new BoxRenderable(renderer, {
|
|
45
|
+
width: "100%",
|
|
46
|
+
flexDirection: "column",
|
|
47
|
+
marginBottom: 1,
|
|
48
|
+
})
|
|
49
|
+
this.add(this.warningBox)
|
|
50
|
+
this.updateWarnings()
|
|
51
|
+
|
|
52
|
+
// 2. Category Tabs
|
|
53
|
+
const tabOptions = this.getTabOptions()
|
|
54
|
+
this.tabs = new TabSelectRenderable(renderer, {
|
|
55
|
+
width: "100%" /** Explicit width needed for tabs usually */,
|
|
56
|
+
options: tabOptions,
|
|
57
|
+
tabWidth: 12,
|
|
58
|
+
showUnderline: false,
|
|
59
|
+
showDescription: false,
|
|
60
|
+
selectedBackgroundColor: "#4a9eff",
|
|
61
|
+
textColor: "#555555",
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
// Event listener replaced by manual key handling for instant switching
|
|
65
|
+
/*
|
|
66
|
+
this.tabs.on(TabSelectRenderableEvents.ITEM_SELECTED, (index) => {
|
|
67
|
+
if (this.currentCategoryIndex !== index) {
|
|
68
|
+
this.currentCategoryIndex = index
|
|
69
|
+
this.updateAppList()
|
|
70
|
+
this.activeComponent = "tabs"
|
|
71
|
+
this.tabs.focus()
|
|
72
|
+
}
|
|
73
|
+
})
|
|
74
|
+
*/
|
|
75
|
+
|
|
76
|
+
this.add(this.tabs)
|
|
77
|
+
|
|
78
|
+
// Spacer
|
|
79
|
+
this.add(new TextRenderable(renderer, { content: " " }))
|
|
80
|
+
|
|
81
|
+
// 3. App List
|
|
82
|
+
this.appList = new SelectRenderable(renderer, {
|
|
83
|
+
width: "100%",
|
|
84
|
+
flexGrow: 1,
|
|
85
|
+
backgroundColor: "#151525",
|
|
86
|
+
focusedBackgroundColor: "#252545",
|
|
87
|
+
selectedBackgroundColor: "#3a4a6e",
|
|
88
|
+
showScrollIndicator: true,
|
|
89
|
+
options: [], // populated via updateAppList
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
this.appList.on(SelectRenderableEvents.ITEM_SELECTED, (index) => {
|
|
93
|
+
const category = CATEGORY_ORDER[this.currentCategoryIndex]
|
|
94
|
+
const apps = getAppsByCategory()[category.id] || []
|
|
95
|
+
const app = apps[index]
|
|
96
|
+
|
|
97
|
+
if (app) {
|
|
98
|
+
const isEnabled = !this.selectedApps.has(app.id)
|
|
99
|
+
if (isEnabled) {
|
|
100
|
+
this.selectedApps.add(app.id)
|
|
101
|
+
} else {
|
|
102
|
+
this.selectedApps.delete(app.id)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (this.onToggle) {
|
|
106
|
+
this.onToggle(app.id, isEnabled)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Refresh list to show checkmark
|
|
110
|
+
this.updateAppList()
|
|
111
|
+
|
|
112
|
+
// Refresh tabs to show counts
|
|
113
|
+
this.tabs.options = this.getTabOptions()
|
|
114
|
+
// Refresh warnings
|
|
115
|
+
this.updateWarnings()
|
|
116
|
+
}
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
this.add(this.appList)
|
|
120
|
+
|
|
121
|
+
// Initial population
|
|
122
|
+
this.updateAppList()
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
private getTabOptions() {
|
|
126
|
+
return CATEGORY_ORDER.map((cat) => {
|
|
127
|
+
const categoryApps = getAppsByCategory()[cat.id] || []
|
|
128
|
+
const selectedCount = categoryApps.filter((a) => this.selectedApps.has(a.id)).length
|
|
129
|
+
const countStr = selectedCount > 0 ? `(${selectedCount})` : ""
|
|
130
|
+
return {
|
|
131
|
+
name: `${cat.short}${countStr}`,
|
|
132
|
+
value: cat.id,
|
|
133
|
+
description: "", // Required by TabSelectOption
|
|
134
|
+
}
|
|
135
|
+
})
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
private updateAppList() {
|
|
139
|
+
const category = CATEGORY_ORDER[this.currentCategoryIndex]
|
|
140
|
+
const apps = getAppsByCategory()[category.id] || []
|
|
141
|
+
|
|
142
|
+
const options = apps.map((app) => ({
|
|
143
|
+
name: `${this.selectedApps.has(app.id) ? "[✓]" : "[ ]"} ${app.name}`,
|
|
144
|
+
description: `Port ${app.defaultPort} - ${app.description}`,
|
|
145
|
+
}))
|
|
146
|
+
|
|
147
|
+
this.appList.options = options
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
private updateWarnings() {
|
|
151
|
+
// Clear existing
|
|
152
|
+
const children = this.warningBox.getChildren()
|
|
153
|
+
children.forEach((c) => this.warningBox.remove(c.id))
|
|
154
|
+
|
|
155
|
+
const warnings = this.getConflictWarnings()
|
|
156
|
+
|
|
157
|
+
warnings.forEach((w, i) => {
|
|
158
|
+
this.warningBox.add(
|
|
159
|
+
new TextRenderable(this._renderer, {
|
|
160
|
+
id: `warning-${i}`,
|
|
161
|
+
content: `⚠ ${w}`,
|
|
162
|
+
fg: "#ffaa00",
|
|
163
|
+
})
|
|
164
|
+
)
|
|
165
|
+
})
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
private getConflictWarnings(): string[] {
|
|
169
|
+
const warnings: string[] = []
|
|
170
|
+
const check = (list: string[], msg: string) => {
|
|
171
|
+
const found = list.filter((id) => this.selectedApps.has(id as AppId))
|
|
172
|
+
if (found.length > 1) warnings.push(`${msg}: ${found.join(", ")}`)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
check(["homarr", "heimdall", "homepage"], "Multiple dashboards")
|
|
176
|
+
check(["plex", "jellyfin"], "Multiple media servers")
|
|
177
|
+
check(["overseerr", "jellyseerr"], "Multiple request managers")
|
|
178
|
+
check(["prowlarr", "jackett"], "Multiple indexers")
|
|
179
|
+
|
|
180
|
+
return warnings
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// --- Focus Management ---
|
|
184
|
+
|
|
185
|
+
focus() {
|
|
186
|
+
if (this.activeComponent === "tabs") {
|
|
187
|
+
// Don't focus tabs to avoid double key handling
|
|
188
|
+
this.appList.blur()
|
|
189
|
+
} else {
|
|
190
|
+
this.appList.focus()
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
handleKey(key: KeyEvent): boolean {
|
|
195
|
+
// Return true if handled
|
|
196
|
+
// Navigation between internal components
|
|
197
|
+
if (this.activeComponent === "tabs") {
|
|
198
|
+
if (key.name === "down") {
|
|
199
|
+
this.activeComponent = "list"
|
|
200
|
+
this.tabs.blur()
|
|
201
|
+
this.appList.focus()
|
|
202
|
+
return true
|
|
203
|
+
}
|
|
204
|
+
if (key.name === "left") {
|
|
205
|
+
this.switchCategory(-1)
|
|
206
|
+
return true
|
|
207
|
+
}
|
|
208
|
+
if (key.name === "right") {
|
|
209
|
+
this.switchCategory(1)
|
|
210
|
+
return true
|
|
211
|
+
}
|
|
212
|
+
} else {
|
|
213
|
+
// List active
|
|
214
|
+
if (key.name === "up" && this.appList.selectedIndex === 0) {
|
|
215
|
+
this.activeComponent = "tabs"
|
|
216
|
+
this.appList.blur()
|
|
217
|
+
return true
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Allow switching category from list too?
|
|
221
|
+
if (key.name === "left" || key.name === "[") {
|
|
222
|
+
this.switchCategory(-1)
|
|
223
|
+
return true
|
|
224
|
+
}
|
|
225
|
+
if (key.name === "right" || key.name === "]") {
|
|
226
|
+
this.switchCategory(1)
|
|
227
|
+
return true
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return false
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
private toggleFocus() {
|
|
235
|
+
if (this.activeComponent === "tabs") {
|
|
236
|
+
this.activeComponent = "list"
|
|
237
|
+
this.appList.focus()
|
|
238
|
+
} else {
|
|
239
|
+
this.activeComponent = "tabs"
|
|
240
|
+
this.appList.blur()
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
private switchCategory(delta: number) {
|
|
245
|
+
let newIndex = this.currentCategoryIndex + delta
|
|
246
|
+
if (newIndex < 0) newIndex = 0
|
|
247
|
+
if (newIndex >= CATEGORY_ORDER.length) newIndex = CATEGORY_ORDER.length - 1
|
|
248
|
+
|
|
249
|
+
if (newIndex !== this.currentCategoryIndex) {
|
|
250
|
+
this.currentCategoryIndex = newIndex
|
|
251
|
+
// Sync Tabs
|
|
252
|
+
this.tabs.setSelectedIndex(newIndex)
|
|
253
|
+
this.updateAppList()
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BoxRenderable,
|
|
3
|
+
TextareaRenderable,
|
|
4
|
+
TextRenderable,
|
|
5
|
+
CliRenderer,
|
|
6
|
+
RenderContext,
|
|
7
|
+
BoxOptions,
|
|
8
|
+
RGBA,
|
|
9
|
+
KeyEvent,
|
|
10
|
+
} from "@opentui/core"
|
|
11
|
+
|
|
12
|
+
export interface FileEditorOptions extends BoxOptions {
|
|
13
|
+
filename: string
|
|
14
|
+
initialContent: string
|
|
15
|
+
onSave: (content: string) => void
|
|
16
|
+
onCancel: () => void
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class FileEditor extends BoxRenderable {
|
|
20
|
+
private textarea: TextareaRenderable
|
|
21
|
+
private helpText: TextRenderable
|
|
22
|
+
private onSave: (content: string) => void
|
|
23
|
+
private onCancel: () => void
|
|
24
|
+
private filename: string
|
|
25
|
+
private renderer: CliRenderer
|
|
26
|
+
private keyHandler: ((k: KeyEvent) => void) | null = null
|
|
27
|
+
|
|
28
|
+
constructor(renderer: CliRenderer | RenderContext, options: FileEditorOptions) {
|
|
29
|
+
super(renderer, {
|
|
30
|
+
...options,
|
|
31
|
+
border: true,
|
|
32
|
+
borderStyle: "double",
|
|
33
|
+
title: `Editing: ${options.filename}`,
|
|
34
|
+
titleAlignment: "center",
|
|
35
|
+
flexDirection: "column",
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
this.renderer = renderer as CliRenderer
|
|
39
|
+
this.onSave = options.onSave
|
|
40
|
+
this.onCancel = options.onCancel
|
|
41
|
+
this.filename = options.filename
|
|
42
|
+
|
|
43
|
+
this.textarea = new TextareaRenderable(renderer, {
|
|
44
|
+
width: "100%",
|
|
45
|
+
flexGrow: 1, // fill remaining space
|
|
46
|
+
initialValue: options.initialContent,
|
|
47
|
+
showCursor: true,
|
|
48
|
+
wrapMode: "none",
|
|
49
|
+
})
|
|
50
|
+
this.add(this.textarea)
|
|
51
|
+
|
|
52
|
+
// Help Footer
|
|
53
|
+
this.helpText = new TextRenderable(renderer, {
|
|
54
|
+
content: "Ctrl+S: Save | ESC: Cancel",
|
|
55
|
+
width: "100%",
|
|
56
|
+
height: 1,
|
|
57
|
+
fg: RGBA.fromHex("#888888"),
|
|
58
|
+
})
|
|
59
|
+
this.add(this.helpText)
|
|
60
|
+
|
|
61
|
+
// Use global key handler for reliability
|
|
62
|
+
this.keyHandler = (key: KeyEvent) => {
|
|
63
|
+
// If we are not visible or destroyed, don't handle keys
|
|
64
|
+
if (this.isDestroyed || !this.visible) return
|
|
65
|
+
|
|
66
|
+
if (key.name === "s" && key.ctrl) {
|
|
67
|
+
this.onSave(this.textarea.plainText)
|
|
68
|
+
} else if (key.name === "escape") {
|
|
69
|
+
this.onCancel()
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
this.renderer.keyInput.on("keypress", this.keyHandler)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
override destroy(): void {
|
|
77
|
+
if (this.keyHandler) {
|
|
78
|
+
this.renderer.keyInput.off("keypress", this.keyHandler)
|
|
79
|
+
this.keyHandler = null
|
|
80
|
+
}
|
|
81
|
+
super.destroy()
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
focus() {
|
|
85
|
+
this.textarea.focus()
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
getValue(): string {
|
|
89
|
+
return this.textarea.plainText
|
|
90
|
+
}
|
|
91
|
+
}
|