opencode-git-plugin 0.2.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.
@@ -0,0 +1,108 @@
1
+ import { createMemo } from "solid-js"
2
+ import type { Context } from "@opencode/plugin/tui/context"
3
+ import { STAGED_STORE_KEY } from "../config"
4
+ import { parseStagedPorcelain, shellQuote, type GitRunner } from "../git"
5
+ import { friendlyError } from "../ui"
6
+
7
+ export type Staging = ReturnType<typeof createStaging>
8
+
9
+ export function createStaging(
10
+ ctx: Context,
11
+ git: GitRunner,
12
+ location: () => { directory: string },
13
+ refreshWorkingCopy: () => Promise<void>,
14
+ ) {
15
+ const [stagedMem, setStagedMem] = ctx.storage.memory(STAGED_STORE_KEY, {
16
+ initial: { directory: "", files: [] as string[] },
17
+ })
18
+ const staged = createMemo(() => new Set(stagedMem.files))
19
+
20
+ if (stagedMem.directory !== location().directory) {
21
+ setStagedMem((draft) => {
22
+ draft.directory = location().directory
23
+ draft.files = []
24
+ })
25
+ }
26
+
27
+ let refreshTask: Promise<void> | undefined
28
+
29
+ const fetchStatuses = async () => {
30
+ try {
31
+ const res = await git.run("status --porcelain=v1 -z -uall --no-renames")
32
+ if (res.exit === 0) {
33
+ const files = [...parseStagedPorcelain(res.output)]
34
+ setStagedMem((draft) => {
35
+ draft.files = files
36
+ })
37
+ }
38
+ } catch {
39
+ }
40
+ }
41
+
42
+ const scheduleRefresh = async () => {
43
+ if (refreshTask) return refreshTask
44
+ refreshTask = git.enqueue(fetchStatuses).finally(() => {
45
+ refreshTask = undefined
46
+ })
47
+ return refreshTask
48
+ }
49
+
50
+ const applyChange = (file: string, stage: boolean) => {
51
+ setStagedMem((draft) => {
52
+ const next = new Set(draft.files)
53
+ if (stage) next.add(file)
54
+ else next.delete(file)
55
+ draft.files = [...next]
56
+ })
57
+ void git.enqueue(async () => {
58
+ try {
59
+ let res = await git.run(stage ? `add -- ${shellQuote(file)}` : `restore --staged -- ${shellQuote(file)}`)
60
+ if (!stage && res.exit !== 0 && /could not resolve HEAD|ambiguous argument ['"]?HEAD|invalid object name ['"]?HEAD/i.test(res.output)) {
61
+ res = await git.run(`rm --cached -- ${shellQuote(file)}`)
62
+ }
63
+ if (res.exit !== 0) {
64
+ ctx.ui.toast.show({
65
+ message: `git ${stage ? "add" : "restore"} failed: ${res.output.trim().slice(0, 200)}`,
66
+ variant: "error",
67
+ })
68
+ await fetchStatuses()
69
+ return
70
+ }
71
+ await fetchStatuses()
72
+ } catch (e) {
73
+ ctx.ui.toast.show({ message: `git failed: ${friendlyError(e)}`, variant: "error" })
74
+ await fetchStatuses()
75
+ return
76
+ }
77
+ })
78
+ }
79
+
80
+ const toggle = (file: string) => {
81
+ applyChange(file, !staged().has(file))
82
+ }
83
+
84
+ const commit = async () => {
85
+ await scheduleRefresh()
86
+ if (staged().size === 0) {
87
+ ctx.ui.toast.show({ message: "Nothing staged — stage files with → first", variant: "warning" })
88
+ return
89
+ }
90
+ const message = await ctx.ui.dialog.prompt({ title: "Commit message", placeholder: "Describe the change" })
91
+ if (!message) return
92
+ await git.enqueue(async () => {
93
+ try {
94
+ const res = await git.run(`commit -m ${shellQuote(message)}`)
95
+ if (res.exit !== 0) {
96
+ ctx.ui.toast.show({ message: `git commit failed: ${res.output.trim().slice(0, 200)}`, variant: "error" })
97
+ } else {
98
+ ctx.ui.toast.show({ message: "Committed", variant: "success" })
99
+ }
100
+ } catch (e) {
101
+ ctx.ui.toast.show({ message: `git commit failed: ${friendlyError(e)}`, variant: "error" })
102
+ }
103
+ await refreshWorkingCopy()
104
+ })
105
+ }
106
+
107
+ return { staged, scheduleRefresh, applyChange, toggle, commit }
108
+ }
@@ -0,0 +1,29 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+ import { Show } from "solid-js"
3
+ import type { Context } from "@opencode/plugin/tui/context"
4
+
5
+ export function SidebarStatus(props: {
6
+ ctx: Context
7
+ loading: boolean
8
+ error: string | undefined
9
+ hasFiles: boolean
10
+ branch: string
11
+ }) {
12
+ const ctx = props.ctx
13
+ return (
14
+ <>
15
+ <Show when={props.loading}>
16
+ <text fg={ctx.theme.text.muted}>Loading…</text>
17
+ </Show>
18
+ <Show when={!props.loading && props.error}>
19
+ <text fg={ctx.theme.text.feedback.error.base}>{props.error}</text>
20
+ </Show>
21
+ <Show when={!props.loading && !props.error && !props.hasFiles && !props.branch}>
22
+ <text fg={ctx.theme.text.muted}>Empty repo — no commits yet</text>
23
+ </Show>
24
+ <Show when={!props.loading && !props.error && !props.hasFiles && !!props.branch}>
25
+ <text fg={ctx.theme.text.muted}>Working tree clean</text>
26
+ </Show>
27
+ </>
28
+ )
29
+ }
@@ -0,0 +1,159 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+ import { createEffect, createSignal, onCleanup, Show } from "solid-js"
3
+ import type { Context } from "@opencode/plugin/tui/context"
4
+ import { createDiffOpener } from "../diff"
5
+ import { createGitRunner } from "../git"
6
+ import { setDiffClosedHandler, useDiffStore } from "../state"
7
+ import type { ChangeFile, LayoutResizeEmitter, RenderNode } from "../types"
8
+ import { FileList } from "./file-list"
9
+ import { bindSidebarFocus } from "./focus"
10
+ import { SidebarHeader } from "./header"
11
+ import { registerSidebarKeymaps } from "./keymap"
12
+ import { createListNavigation } from "./list-navigation"
13
+ import { createStaging } from "./staging"
14
+ import { SidebarStatus } from "./status"
15
+ import { createWorkingCopy } from "./working-copy"
16
+
17
+ export function GitChangesView(props: { ctx: Context }) {
18
+ const { ctx } = props
19
+ const [sidebarWidth, setSidebarWidth] = createSignal(46)
20
+ const [diffView] = useDiffStore(ctx)
21
+ let sidebarNode: RenderNode | undefined
22
+
23
+ const currentLocation = () => {
24
+ const loc = ctx.location ?? ctx.data.location.default()
25
+ return { directory: loc.directory }
26
+ }
27
+
28
+ const git = createGitRunner(ctx, () => currentLocation().directory)
29
+ const openDiff = createDiffOpener(ctx, currentLocation)
30
+ const workingCopy = createWorkingCopy(ctx, currentLocation, (count) => {
31
+ nav.clampSelection(count)
32
+ void staging.scheduleRefresh()
33
+ })
34
+ const staging = createStaging(ctx, git, currentLocation, workingCopy.refresh)
35
+ const nav = createListNavigation(ctx, workingCopy.files)
36
+
37
+ const openSelectedDiff = () => {
38
+ const f = nav.selectedFile()
39
+ if (f) void openDiff(f).catch(() => {})
40
+ }
41
+ const setSelectedFileStaged = (stage: boolean) => {
42
+ const f = nav.selectedFile()
43
+ if (f) staging.applyChange(f.file, stage)
44
+ }
45
+ const selectFile = (f: ChangeFile, index: number) => {
46
+ nav.setSelected(index)
47
+ if (nav.stageMode()) staging.toggle(f.file)
48
+ else void openDiff(f).catch(() => {})
49
+ }
50
+
51
+ void workingCopy.refresh().catch(() => {})
52
+ void staging.scheduleRefresh().catch(() => {})
53
+
54
+ registerSidebarKeymaps(ctx, {
55
+ files: workingCopy.files,
56
+ nav,
57
+ diffOpen: () => diffView.panelOpen,
58
+ requestRefresh: workingCopy.requestRefresh,
59
+ actions: {
60
+ next: nav.selectNext,
61
+ prev: nav.selectPrevious,
62
+ open: openSelectedDiff,
63
+ setStaged: setSelectedFileStaged,
64
+ commit: () => void staging.commit(),
65
+ },
66
+ })
67
+
68
+ const restoreListFocus = () => {
69
+ if (!nav.navMode() || diffView.panelOpen) return
70
+ nav.ensure()
71
+ }
72
+ setDiffClosedHandler(() => {
73
+ if (!nav.navMode() && workingCopy.files().length > 0) nav.enter()
74
+ restoreListFocus()
75
+ })
76
+ createEffect((wasOpen: boolean | undefined) => {
77
+ const open = diffView.panelOpen
78
+ if (wasOpen === true && open === false && nav.navMode()) {
79
+ setTimeout(() => restoreListFocus(), 0)
80
+ }
81
+ return open
82
+ })
83
+ bindSidebarFocus(ctx, {
84
+ container: () => sidebarNode,
85
+ active: () => nav.navMode() && !diffView.panelOpen,
86
+ exit: nav.exit,
87
+ })
88
+ onCleanup(() => setDiffClosedHandler(undefined))
89
+
90
+ const setSidebarRef = (el: RenderNode) => {
91
+ sidebarNode = el
92
+ setSidebarWidth(el.width)
93
+ const onNodeResize = () => setSidebarWidth(el.width)
94
+ ;(el as unknown as LayoutResizeEmitter).on("resize", onNodeResize)
95
+ onCleanup(() => (el as unknown as LayoutResizeEmitter).off("resize", onNodeResize))
96
+ }
97
+
98
+ const ready = () => !workingCopy.loading() && !workingCopy.error()
99
+ const hasFiles = () => workingCopy.files().length > 0
100
+
101
+ const sidebarBody = (
102
+ <>
103
+ <SidebarHeader
104
+ ctx={ctx}
105
+ branch={workingCopy.branch()}
106
+ fileCount={workingCopy.files().length}
107
+ ready={ready()}
108
+ additions={workingCopy.additions()}
109
+ deletions={workingCopy.deletions()}
110
+ navMode={nav.navMode()}
111
+ stageMode={nav.stageMode()}
112
+ hasStaged={staging.staged().size > 0}
113
+ onBranchClick={nav.toggleStageMode}
114
+ onCommit={() => void staging.commit()}
115
+ />
116
+ <SidebarStatus
117
+ ctx={ctx}
118
+ loading={workingCopy.loading()}
119
+ error={workingCopy.error()}
120
+ hasFiles={hasFiles()}
121
+ branch={workingCopy.branch()}
122
+ />
123
+ <Show when={ready() && hasFiles()}>
124
+ <FileList
125
+ ctx={ctx}
126
+ files={workingCopy.files()}
127
+ staged={staging.staged()}
128
+ selected={nav.selected()}
129
+ navMode={nav.navMode()}
130
+ width={sidebarWidth()}
131
+ onSelect={selectFile}
132
+ />
133
+ </Show>
134
+ </>
135
+ )
136
+
137
+ return (
138
+ <Show
139
+ when={nav.navMode()}
140
+ fallback={
141
+ <box flexDirection="column" gap={1} ref={setSidebarRef}>
142
+ {sidebarBody}
143
+ </box>
144
+ }
145
+ >
146
+ <box
147
+ flexDirection="column"
148
+ gap={1}
149
+ border={["top", "bottom"]}
150
+ borderStyle="rounded"
151
+ borderColor={ctx.theme.text.feedback.warning.base}
152
+ focusedBorderColor={ctx.theme.text.feedback.warning.base}
153
+ ref={setSidebarRef}
154
+ >
155
+ {sidebarBody}
156
+ </box>
157
+ </Show>
158
+ )
159
+ }
@@ -0,0 +1,81 @@
1
+ import { createEffect, createMemo, createSignal, onCleanup } from "solid-js"
2
+ import type { Context } from "@opencode/plugin/tui/context"
3
+ import { EVENT_REFRESH_DEBOUNCE_MS, REFRESH_STORE_KEY, intervalMs } from "../config"
4
+ import { friendlyError } from "../ui"
5
+ import type { ChangeFile } from "../types"
6
+
7
+ export type WorkingCopy = ReturnType<typeof createWorkingCopy>
8
+
9
+ export function createWorkingCopy(
10
+ ctx: Context,
11
+ location: () => { directory: string },
12
+ onRefreshed: (count: number) => void,
13
+ ) {
14
+ const [files, setFiles] = createSignal<ChangeFile[]>([])
15
+ const [branch, setBranch] = createSignal<string>("")
16
+ const [loading, setLoading] = createSignal(true)
17
+ const [error, setError] = createSignal<string | undefined>(undefined)
18
+
19
+ let isRefreshing = false
20
+ let lastEventRefreshAt = 0
21
+
22
+ const refresh = async () => {
23
+ if (isRefreshing) return
24
+ isRefreshing = true
25
+ try {
26
+ const loc = location()
27
+ const [statusRes, infoRes] = await Promise.all([
28
+ ctx.client.vcs.status({ location: loc }),
29
+ ctx.client.vcs.get({ location: loc }),
30
+ ])
31
+ const prevByFile = new Map(files().map((f) => [f.file, f]))
32
+ setFiles(
33
+ statusRes.data.map((d) => {
34
+ const p = prevByFile.get(d.file)
35
+ return p && p.additions === d.additions && p.deletions === d.deletions && p.status === d.status
36
+ ? p
37
+ : { ...d }
38
+ }),
39
+ )
40
+ setBranch(infoRes.data.branch.current ?? "")
41
+ setError(undefined)
42
+ onRefreshed(statusRes.data.length)
43
+ } catch (e) {
44
+ setError(friendlyError(e))
45
+ } finally {
46
+ setLoading(false)
47
+ isRefreshing = false
48
+ }
49
+ }
50
+
51
+ const additions = createMemo(() => files().reduce((n, f) => n + f.additions, 0))
52
+ const deletions = createMemo(() => files().reduce((n, f) => n + f.deletions, 0))
53
+
54
+ const [refreshSignal, bumpRefresh] = ctx.storage.memory(REFRESH_STORE_KEY, { initial: { nonce: 0 } })
55
+ const requestRefresh = () =>
56
+ bumpRefresh((draft) => {
57
+ draft.nonce += 1
58
+ })
59
+
60
+ createEffect((prev: number | undefined) => {
61
+ const nonce = refreshSignal.nonce
62
+ if (prev !== undefined) void refresh().catch(() => {})
63
+ return nonce
64
+ })
65
+
66
+ const timer = setInterval(() => void refresh().catch(() => {}), intervalMs(ctx))
67
+ const stopListening = ctx.data.listen(({ details }: { details: { type: string } }) => {
68
+ const t = details.type
69
+ if (!(t.includes("session") || t.includes("vcs") || t.includes("server.connected") || t.includes("location"))) return
70
+ const now = Date.now()
71
+ if (now - lastEventRefreshAt < EVENT_REFRESH_DEBOUNCE_MS) return
72
+ lastEventRefreshAt = now
73
+ void refresh().catch(() => {})
74
+ })
75
+ onCleanup(() => {
76
+ clearInterval(timer)
77
+ stopListening()
78
+ })
79
+
80
+ return { files, branch, loading, error, additions, deletions, refresh, requestRefresh }
81
+ }
@@ -0,0 +1,34 @@
1
+ import type { Context } from "@opencode/plugin/tui/context"
2
+ import { DIFF_STORE_KEY } from "../config"
3
+ import type { DiffViewState } from "../types"
4
+
5
+ export const initialDiffState: DiffViewState = {
6
+ file: "",
7
+ additions: 0,
8
+ deletions: 0,
9
+ lines: [],
10
+ patch: "",
11
+ message: "",
12
+ truncated: false,
13
+ loading: false,
14
+ query: "",
15
+ matchCursor: 0,
16
+ panelOpen: false,
17
+ }
18
+
19
+ export function useDiffStore(ctx: Context) {
20
+ return ctx.storage.memory(DIFF_STORE_KEY, { initial: initialDiffState })
21
+ }
22
+
23
+ let diffClosedHandler: (() => void) | undefined
24
+
25
+ export function setDiffClosedHandler(handler: (() => void) | undefined): void {
26
+ diffClosedHandler = handler
27
+ }
28
+
29
+ export function notifyDiffClosed(): void {
30
+ try {
31
+ diffClosedHandler?.()
32
+ } catch {
33
+ }
34
+ }
@@ -0,0 +1 @@
1
+ export * from "./diff-store"
package/src/tui.tsx ADDED
@@ -0,0 +1,25 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+ import { Plugin } from "@opencode/plugin/tui"
3
+ import { DiffPanel } from "./diff"
4
+ import { GitChangesView } from "./sidebar"
5
+
6
+ export { parseDiff, parseStagedPorcelain } from "./git"
7
+
8
+ export default Plugin.define({
9
+ id: "git-panel.cli",
10
+ setup(ctx) {
11
+ const unregisterSidebar = ctx.ui.slot({
12
+ append: "sidebar.content",
13
+ render: () => <GitChangesView ctx={ctx} />,
14
+ })
15
+ const unregisterPanel = ctx.ui.slot({
16
+ append: "session.panel",
17
+ render: (input) => <DiffPanel ctx={ctx} input={input} />,
18
+ })
19
+
20
+ return () => {
21
+ unregisterSidebar()
22
+ unregisterPanel()
23
+ }
24
+ },
25
+ })
package/src/types.ts ADDED
@@ -0,0 +1,63 @@
1
+ import type { Context } from "@opencode/plugin/tui/context"
2
+
3
+ export type RenderNode = {
4
+ parent?: RenderNode | null
5
+ width: number
6
+ }
7
+
8
+ export type ScrollHandle = {
9
+ scrollTop: number
10
+ scrollTo(options: { x: number; y: number }): void
11
+ }
12
+
13
+ export type ChangeFile = {
14
+ file: string
15
+ additions: number
16
+ deletions: number
17
+ status: "added" | "deleted" | "modified"
18
+ }
19
+
20
+ export type DiffLineKind = "hunk" | "add" | "del" | "context" | "meta"
21
+ export type DiffLine = { kind: DiffLineKind; text: string; line?: number }
22
+
23
+ export type DiffViewState = {
24
+ file: string
25
+ additions: number
26
+ deletions: number
27
+ lines: DiffLine[]
28
+ patch: string
29
+ message: string
30
+ truncated: boolean
31
+ loading: boolean
32
+ query: string
33
+ matchCursor: number
34
+ panelOpen: boolean
35
+ }
36
+
37
+ export type DiffRow = {
38
+ kind: DiffLineKind
39
+ text: string
40
+ line: number | undefined
41
+ state: "current" | "hit" | "plain"
42
+ }
43
+
44
+ export type FileBinds = {
45
+ next?: string
46
+ prev?: string
47
+ open?: string
48
+ stage?: string
49
+ unstage?: string
50
+ commit?: string
51
+ }
52
+
53
+ export type RendererFocusEvents = {
54
+ on(event: "focused_renderable" | "focused_editor", listener: (...args: never[]) => void): void
55
+ off(event: "focused_renderable" | "focused_editor", listener: (...args: never[]) => void): void
56
+ }
57
+
58
+ export type LayoutResizeEmitter = {
59
+ on(event: "resize", listener: () => void): void
60
+ off(event: "resize", listener: () => void): void
61
+ }
62
+
63
+ export type Theme = Context["theme"]
@@ -0,0 +1,67 @@
1
+ import type { Context } from "@opencode/plugin/tui/context"
2
+ import type { ChangeFile, DiffLineKind } from "../types"
3
+ import { FILE_ROW_BADGE_WIDTH } from "../config"
4
+
5
+ type Theme = Context["theme"]
6
+ type ThemeColor = Theme["text"]["base"]
7
+
8
+ const ELLIPSIS = "..."
9
+ const runtime = globalThis as { Bun?: { stringWidth?: (text: string) => number } }
10
+ const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" })
11
+
12
+ export function displayWidth(text: string): number {
13
+ return runtime.Bun?.stringWidth?.(text) ?? [...text].length
14
+ }
15
+
16
+ export function truncatePath(path: string, max: number): string {
17
+ if (displayWidth(path) <= max) return path
18
+ const budget = max - ELLIPSIS.length
19
+ if (budget <= 0) return ELLIPSIS.slice(0, Math.max(0, max))
20
+ const parts = Array.from(graphemes.segment(path), (s) => s.segment)
21
+ let width = 0
22
+ let start = parts.length
23
+ for (let i = parts.length - 1; i >= 0; i--) {
24
+ const w = displayWidth(parts[i])
25
+ if (width + w > budget) break
26
+ width += w
27
+ start = i
28
+ }
29
+ return ELLIPSIS + parts.slice(start).join("")
30
+ }
31
+
32
+ export function maxTextWidth(available: number, reserved: number): number {
33
+ return Math.max(8, available - reserved)
34
+ }
35
+
36
+ export function filePathMaxWidth(rowWidth: number, row: { additions: number; deletions: number }): number {
37
+ const gaps = 4
38
+ const fixed = FILE_ROW_BADGE_WIDTH + displayWidth(`+${row.additions}`) + displayWidth(`-${row.deletions}`)
39
+ return maxTextWidth(rowWidth, fixed + gaps)
40
+ }
41
+
42
+ export function friendlyError(e: unknown): string {
43
+ const msg = e instanceof Error ? e.message : String(e)
44
+ if (/not a git repository|not a repository|no git|vcs|worktree/i.test(msg)) return "Not a git repository"
45
+ return msg.length > 120 ? `${msg.slice(0, 117)}…` : msg
46
+ }
47
+
48
+ export function statusBadge(status: ChangeFile["status"], theme: Theme): { label: string; color: ThemeColor } {
49
+ if (status === "added") return { label: "A", color: theme.text.feedback.success.base }
50
+ if (status === "deleted") return { label: "D", color: theme.text.feedback.error.base }
51
+ return { label: "M", color: theme.text.feedback.warning.base }
52
+ }
53
+
54
+ export function diffLineStyle(theme: Theme, kind: DiffLineKind): { fg: ThemeColor; bg: ThemeColor | undefined } {
55
+ if (kind === "hunk") return { fg: theme.diff.text.hunkHeader, bg: undefined }
56
+ if (kind === "add") return { fg: theme.diff.text.added, bg: theme.diff.background.added }
57
+ if (kind === "del") return { fg: theme.diff.text.removed, bg: theme.diff.background.removed }
58
+ if (kind === "meta") return { fg: theme.text.muted, bg: undefined }
59
+ return { fg: theme.diff.text.context, bg: theme.diff.background.context }
60
+ }
61
+
62
+ export function diffLinePrefix(kind: DiffLineKind): string {
63
+ if (kind === "add") return "+"
64
+ if (kind === "del") return "-"
65
+ if (kind === "context") return " "
66
+ return ""
67
+ }
@@ -0,0 +1,2 @@
1
+ export * from "./format"
2
+ export * from "./syntax"
@@ -0,0 +1,61 @@
1
+ const SYNTAX_BY_LANGUAGE: Array<[string, string[]]> = [
2
+ ["typescript", ["ts", "mts", "cts", "tsx", "mtsx", "js", "mjs", "cjs", "jsx"]],
3
+ ["python", ["py"]],
4
+ ["rust", ["rs"]],
5
+ ["go", ["go"]],
6
+ ["java", ["java"]],
7
+ ["kotlin", ["kt", "kts"]],
8
+ ["ruby", ["rb"]],
9
+ ["php", ["php"]],
10
+ ["swift", ["swift"]],
11
+ ["c", ["c", "h"]],
12
+ ["cpp", ["cpp", "cc", "hpp"]],
13
+ ["csharp", ["cs"]],
14
+ ["css", ["css"]],
15
+ ["scss", ["scss"]],
16
+ ["less", ["less"]],
17
+ ["html", ["html", "htm"]],
18
+ ["xml", ["xml"]],
19
+ ["vue", ["vue"]],
20
+ ["svelte", ["svelte"]],
21
+ ["astro", ["astro"]],
22
+ ["json", ["json"]],
23
+ ["yaml", ["yaml", "yml"]],
24
+ ["toml", ["toml"]],
25
+ ["ini", ["ini"]],
26
+ ["markdown", ["md", "markdown"]],
27
+ ["bash", ["sh", "bash", "zsh"]],
28
+ ["sql", ["sql"]],
29
+ ["lua", ["lua"]],
30
+ ["r", ["r"]],
31
+ ["julia", ["jl"]],
32
+ ["haskell", ["hs"]],
33
+ ["ocaml", ["ml", "mli"]],
34
+ ["elixir", ["ex", "exs"]],
35
+ ["erlang", ["erl"]],
36
+ ["clojure", ["clj", "cljs"]],
37
+ ["scala", ["scala"]],
38
+ ["groovy", ["groovy"]],
39
+ ["dart", ["dart"]],
40
+ ["zig", ["zig"]],
41
+ ["nix", ["nix"]],
42
+ ["terraform", ["tf"]],
43
+ ["hcl", ["hcl"]],
44
+ ["dockerfile", ["dockerfile"]],
45
+ ["latex", ["tex"]],
46
+ ["diff", ["diff", "patch"]],
47
+ ]
48
+
49
+ const LANGUAGE_BY_EXTENSION: Record<string, string> = Object.fromEntries(
50
+ SYNTAX_BY_LANGUAGE.flatMap(([lang, exts]) => exts.map((e) => [`.${e}`, lang])),
51
+ )
52
+
53
+ export function syntaxFiletype(file: string): string | undefined {
54
+ const dot = file.lastIndexOf(".")
55
+ if (dot < 0) {
56
+ const base = file.split("/").pop()?.toLowerCase() ?? ""
57
+ if (base === "dockerfile" || base === "makefile") return base
58
+ return undefined
59
+ }
60
+ return LANGUAGE_BY_EXTENSION[file.slice(dot).toLowerCase()]
61
+ }
package/tui.ts ADDED
@@ -0,0 +1 @@
1
+ export { default } from "./src/tui"