@kitlangton/ghui 0.1.21 → 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,16 @@
1
+ import { context } from "@ghui/keymap"
2
+
3
+ export interface LabelModalCtx {
4
+ readonly closeModal: () => void
5
+ readonly toggleSelected: () => void
6
+ readonly moveSelection: (delta: -1 | 1) => void
7
+ }
8
+
9
+ const Label = context<LabelModalCtx>()
10
+
11
+ export const labelModalKeymap = Label(
12
+ { id: "label-modal.close", title: "Close", keys: ["escape"], run: (s) => s.closeModal() },
13
+ { id: "label-modal.toggle", title: "Toggle label", keys: ["return"], run: (s) => s.toggleSelected() },
14
+ { id: "label-modal.up", title: "Up", keys: ["k", "up"], run: (s) => s.moveSelection(-1) },
15
+ { id: "label-modal.down", title: "Down", keys: ["j", "down"], run: (s) => s.moveSelection(1) },
16
+ )
@@ -0,0 +1,119 @@
1
+ import { context } from "@ghui/keymap"
2
+ import { countedVerticalBindings } from "./helpers.ts"
3
+
4
+ export interface ListNavCtx {
5
+ readonly halfPage: number
6
+ readonly visibleCount: number
7
+ readonly hasFilter: boolean
8
+ readonly canScrollDetailPreview: boolean
9
+ readonly runCommandById: (id: string) => void
10
+ readonly switchQueueMode: (delta: 1 | -1) => void
11
+ readonly scrollDetailPreviewBy: (delta: number) => void
12
+ readonly scrollDetailPreviewTo: (line: number) => void
13
+ readonly clearFilter: () => void
14
+ readonly stepSelected: (delta: number) => void
15
+ readonly stepSelectedUp: (count?: number) => void
16
+ readonly stepSelectedDown: (count?: number) => void
17
+ readonly stepSelectedUpWrap: () => void
18
+ readonly stepSelectedDownWithLoadMore: () => void
19
+ readonly moveSelectedToPreviousGroup: () => void
20
+ readonly moveSelectedToNextGroup: () => void
21
+ readonly setSelected: (index: number) => void
22
+ }
23
+
24
+ const List = context<ListNavCtx>()
25
+
26
+ export const listNavKeymap = List(
27
+ // Single-key command shortcuts (delegate to existing AppCommand registry)
28
+ { id: "list.filter", title: "Filter", keys: ["/"], run: (s) => s.runCommandById("filter.open") },
29
+ { id: "list.refresh", title: "Refresh", keys: ["r"], run: (s) => s.runCommandById("pull.refresh") },
30
+ { id: "list.theme", title: "Theme", keys: ["t"], run: (s) => s.runCommandById("theme.open") },
31
+ { id: "list.diff", title: "Open diff", keys: ["d"], run: (s) => s.runCommandById("diff.open") },
32
+ { id: "list.labels", title: "Labels", keys: ["l"], run: (s) => s.runCommandById("pull.labels") },
33
+ { id: "list.merge", title: "Merge", keys: ["m", "shift+m"], run: (s) => s.runCommandById("pull.merge") },
34
+ { id: "list.close-pr", title: "Close PR", keys: ["x"], run: (s) => s.runCommandById("pull.close") },
35
+ { id: "list.open-browser", title: "Open in browser", keys: ["o"], run: (s) => s.runCommandById("pull.open-browser") },
36
+ { id: "list.toggle-draft", title: "Toggle draft", keys: ["s", "shift+s"], run: (s) => s.runCommandById("pull.toggle-draft") },
37
+ { id: "list.copy", title: "Copy metadata", keys: ["y"], run: (s) => s.runCommandById("pull.copy-metadata") },
38
+ { id: "list.detail.open", title: "Open details", keys: ["return"], run: (s) => s.runCommandById("detail.open") },
39
+
40
+ // Queue mode tabs
41
+ { id: "list.next-tab", title: "Next view", keys: ["tab"], run: (s) => s.switchQueueMode(1) },
42
+ { id: "list.prev-tab", title: "Previous view", keys: ["shift+tab"], run: (s) => s.switchQueueMode(-1) },
43
+
44
+ // Escape clears filter only when one is set
45
+ {
46
+ id: "list.clear-filter",
47
+ title: "Clear filter",
48
+ keys: ["escape"],
49
+ enabled: (s) => s.hasFilter ? true : "No filter to clear.",
50
+ run: (s) => s.clearFilter(),
51
+ },
52
+
53
+ // Wide-layout detail preview scroll
54
+ {
55
+ id: "list.preview.top",
56
+ title: "Detail preview top",
57
+ keys: ["home"],
58
+ enabled: (s) => s.canScrollDetailPreview ? true : "Detail preview not visible.",
59
+ run: (s) => s.scrollDetailPreviewTo(0),
60
+ },
61
+ {
62
+ id: "list.preview.bottom",
63
+ title: "Detail preview bottom",
64
+ keys: ["end"],
65
+ enabled: (s) => s.canScrollDetailPreview ? true : "Detail preview not visible.",
66
+ run: (s) => s.scrollDetailPreviewTo(Number.MAX_SAFE_INTEGER),
67
+ },
68
+ {
69
+ id: "list.preview.half-up",
70
+ title: "Detail preview ½ up",
71
+ keys: ["pageup"],
72
+ enabled: (s) => s.canScrollDetailPreview ? true : "Detail preview not visible.",
73
+ run: (s) => s.scrollDetailPreviewBy(-s.halfPage),
74
+ },
75
+ {
76
+ id: "list.preview.half-down",
77
+ title: "Detail preview ½ down",
78
+ keys: ["pagedown"],
79
+ enabled: (s) => s.canScrollDetailPreview ? true : "Detail preview not visible.",
80
+ run: (s) => s.scrollDetailPreviewBy(s.halfPage),
81
+ },
82
+
83
+ // Group jumps
84
+ {
85
+ id: "list.group-prev",
86
+ title: "Previous group",
87
+ keys: ["[", "meta+up", "meta+k", "shift+k"],
88
+ run: (s) => s.moveSelectedToPreviousGroup(),
89
+ },
90
+ {
91
+ id: "list.group-next",
92
+ title: "Next group",
93
+ keys: ["]", "meta+down", "meta+j", "shift+j"],
94
+ run: (s) => s.moveSelectedToNextGroup(),
95
+ },
96
+
97
+ // Half-page steps
98
+ { id: "list.half-up", title: "Half page up", keys: ["ctrl+u"], run: (s) => s.stepSelected(-s.halfPage) },
99
+ { id: "list.half-down", title: "Half page down", keys: ["ctrl+d"], run: (s) => s.stepSelected(s.halfPage) },
100
+
101
+ // Vim count prefixes
102
+ ...countedVerticalBindings<ListNavCtx>((s, delta) => {
103
+ if (delta < 0) s.stepSelectedUp(-delta)
104
+ else s.stepSelectedDown(delta)
105
+ }),
106
+
107
+ // Single-step (with wrap up, load-more on down)
108
+ { id: "list.up", title: "Up", keys: ["up", "k"], run: (s) => s.stepSelectedUpWrap() },
109
+ { id: "list.down", title: "Down", keys: ["down", "j"], run: (s) => s.stepSelectedDownWithLoadMore() },
110
+
111
+ // Top / bottom
112
+ { id: "list.top", title: "Top", keys: ["g g"], run: (s) => s.setSelected(0) },
113
+ {
114
+ id: "list.bottom",
115
+ title: "Bottom",
116
+ keys: ["shift+g"],
117
+ run: (s) => s.setSelected(s.visibleCount === 0 ? 0 : s.visibleCount - 1),
118
+ },
119
+ )
@@ -0,0 +1,23 @@
1
+ import { context } from "@ghui/keymap"
2
+
3
+ export interface MergeModalCtx {
4
+ readonly availableActionCount: number
5
+ readonly closeModal: () => void
6
+ readonly confirmMerge: () => void
7
+ readonly moveSelection: (delta: -1 | 1) => void
8
+ }
9
+
10
+ const Merge = context<MergeModalCtx>()
11
+
12
+ export const mergeModalKeymap = Merge(
13
+ { id: "merge-modal.cancel", title: "Cancel", keys: ["escape"], run: (s) => s.closeModal() },
14
+ {
15
+ id: "merge-modal.confirm",
16
+ title: "Merge pull request",
17
+ keys: ["return"],
18
+ enabled: (s) => s.availableActionCount > 0 ? true : "No merge actions available.",
19
+ run: (s) => s.confirmMerge(),
20
+ },
21
+ { id: "merge-modal.up", title: "Up", keys: ["k", "up"], run: (s) => s.moveSelection(-1) },
22
+ { id: "merge-modal.down", title: "Down", keys: ["j", "down"], run: (s) => s.moveSelection(1) },
23
+ )
@@ -0,0 +1,13 @@
1
+ import { context } from "@ghui/keymap"
2
+
3
+ export interface OpenRepositoryModalCtx {
4
+ readonly closeModal: () => void
5
+ readonly openFromInput: () => void
6
+ }
7
+
8
+ const OpenRepo = context<OpenRepositoryModalCtx>()
9
+
10
+ export const openRepositoryModalKeymap = OpenRepo(
11
+ { id: "open-repo.close", title: "Cancel", keys: ["escape"], run: (s) => s.closeModal() },
12
+ { id: "open-repo.open", title: "Open repository", keys: ["return"], run: (s) => s.openFromInput() },
13
+ )
@@ -0,0 +1,49 @@
1
+ import { context } from "@ghui/keymap"
2
+
3
+ export interface ThemeModalCtx {
4
+ readonly filterMode: boolean
5
+ readonly hasFilteredResults: boolean
6
+ readonly closeWithoutSaving: () => void
7
+ readonly clearFilter: () => void
8
+ readonly enterFilterMode: () => void
9
+ readonly confirmSelection: () => void
10
+ readonly moveSelection: (delta: -1 | 1) => void
11
+ }
12
+
13
+ const Theme = context<ThemeModalCtx>()
14
+
15
+ export const themeModalKeymap = Theme(
16
+ {
17
+ id: "theme-modal.escape",
18
+ title: "Cancel",
19
+ keys: ["escape"],
20
+ run: (s) => {
21
+ if (s.filterMode) s.clearFilter()
22
+ else s.closeWithoutSaving()
23
+ },
24
+ },
25
+ { id: "theme-modal.filter", title: "Filter themes", keys: ["/"], run: (s) => s.enterFilterMode() },
26
+ {
27
+ id: "theme-modal.confirm",
28
+ title: "Apply theme",
29
+ keys: ["return"],
30
+ enabled: (s) => s.filterMode && !s.hasFilteredResults ? "No matching themes." : true,
31
+ run: (s) => s.confirmSelection(),
32
+ },
33
+ { id: "theme-modal.up-arrow", title: "Up", keys: ["up"], run: (s) => s.moveSelection(-1) },
34
+ { id: "theme-modal.down-arrow", title: "Down", keys: ["down"], run: (s) => s.moveSelection(1) },
35
+ {
36
+ id: "theme-modal.up-letter",
37
+ title: "Up",
38
+ keys: ["k"],
39
+ when: (s) => !s.filterMode,
40
+ run: (s) => s.moveSelection(-1),
41
+ },
42
+ {
43
+ id: "theme-modal.down-letter",
44
+ title: "Down",
45
+ keys: ["j"],
46
+ when: (s) => !s.filterMode,
47
+ run: (s) => s.moveSelection(1),
48
+ },
49
+ )
@@ -1,4 +1,4 @@
1
- import type { PullRequestItem, PullRequestMergeAction, PullRequestMergeInfo } from "./domain.js"
1
+ import type { PullRequestItem, PullRequestMergeAction, PullRequestMergeInfo, PullRequestState } from "./domain.js"
2
2
 
3
3
  export interface MergeActionDefinition {
4
4
  readonly action: PullRequestMergeAction
@@ -8,6 +8,7 @@ export interface MergeActionDefinition {
8
8
  readonly pastTense: string
9
9
  readonly danger?: boolean
10
10
  readonly refreshOnSuccess?: boolean
11
+ readonly optimisticState?: PullRequestState
11
12
  readonly optimisticAutoMergeEnabled?: boolean
12
13
  readonly isAvailable: (info: PullRequestMergeInfo) => boolean
13
14
  }
@@ -29,6 +30,7 @@ const mergeActionDefinitions = {
29
30
  cliArgs: ["--squash", "--delete-branch"],
30
31
  pastTense: "Merged",
31
32
  refreshOnSuccess: true,
33
+ optimisticState: "merged",
32
34
  isAvailable: isCleanlyMergeable,
33
35
  },
34
36
  auto: {
@@ -57,6 +59,7 @@ const mergeActionDefinitions = {
57
59
  pastTense: "Admin merged",
58
60
  danger: true,
59
61
  refreshOnSuccess: true,
62
+ optimisticState: "merged",
60
63
  isAvailable: (info) => info.state === "open" && !info.isDraft && info.mergeable !== "conflicting",
61
64
  },
62
65
  } as const satisfies Record<PullRequestMergeAction, MergeActionDefinition>
@@ -1,6 +1,6 @@
1
1
  import { Context, Effect, Layer, Schema } from "effect"
2
2
  import { config } from "../config.js"
3
- import { DiffCommentSide, pullRequestQueueSearchQualifier, type CheckItem, type CreatePullRequestCommentInput, type ListPullRequestPageInput, type Mergeable, type PullRequestItem, type PullRequestMergeAction, type PullRequestMergeInfo, type PullRequestPage, type PullRequestQueueMode, type PullRequestReviewComment, type ReviewStatus } from "../domain.js"
3
+ import { DiffCommentSide, pullRequestQueueSearchQualifier, type CheckItem, type CreatePullRequestCommentInput, type ListPullRequestPageInput, type Mergeable, type PullRequestConversationItem, type PullRequestItem, type PullRequestMergeAction, type PullRequestMergeInfo, type PullRequestPage, type PullRequestQueueMode, type PullRequestReviewComment, type ReviewStatus } from "../domain.js"
4
4
  import { getMergeActionDefinition } from "../mergeActions.js"
5
5
  import { CommandError, CommandRunner, type JsonParseError } from "./CommandRunner.js"
6
6
 
@@ -429,18 +429,22 @@ const repositoryParts = (repository: string) => {
429
429
  return owner && name ? { owner, name } : null
430
430
  }
431
431
 
432
+ const rawCommentFields = (comment: RawPullRequestComment, fallbackId: string) => ({
433
+ id: String(comment.id ?? comment.node_id ?? fallbackId),
434
+ author: comment.user?.login ?? "unknown",
435
+ body: comment.body ?? "",
436
+ createdAt: comment.created_at ? new Date(comment.created_at) : null,
437
+ url: comment.html_url ?? comment.url ?? null,
438
+ })
439
+
432
440
  const parsePullRequestComment = (comment: RawPullRequestComment): PullRequestReviewComment | null => {
433
441
  const line = comment.line ?? comment.original_line
434
442
  if (!comment.path || !line || (comment.side !== "LEFT" && comment.side !== "RIGHT")) return null
435
443
  return {
436
- id: String(comment.id ?? comment.node_id ?? `${comment.path}:${comment.side}:${line}:${comment.created_at ?? ""}:${comment.body ?? ""}`),
444
+ ...rawCommentFields(comment, `${comment.path}:${comment.side}:${line}:${comment.created_at ?? ""}:${comment.body ?? ""}`),
437
445
  path: comment.path,
438
446
  line,
439
447
  side: comment.side,
440
- author: comment.user?.login ?? "unknown",
441
- body: comment.body ?? "",
442
- createdAt: comment.created_at ? new Date(comment.created_at) : null,
443
- url: comment.html_url ?? comment.url ?? null,
444
448
  }
445
449
  }
446
450
 
@@ -451,6 +455,24 @@ const parsePullRequestComments = (response: Schema.Schema.Type<typeof CommentsRe
451
455
  })
452
456
  }
453
457
 
458
+ const parseIssueComment = (comment: RawPullRequestComment): PullRequestConversationItem => ({
459
+ _tag: "comment",
460
+ ...rawCommentFields(comment, `${comment.created_at ?? ""}:${comment.body ?? ""}`),
461
+ })
462
+
463
+ const reviewCommentConversationItem = (comment: PullRequestReviewComment): PullRequestConversationItem => ({
464
+ _tag: "review-comment",
465
+ ...comment,
466
+ })
467
+
468
+ const conversationItemTime = (item: PullRequestConversationItem) => item.createdAt?.getTime() ?? Number.MAX_SAFE_INTEGER
469
+
470
+ const sortConversationItems = (items: readonly PullRequestConversationItem[]) =>
471
+ [...items].sort((left, right) => conversationItemTime(left) - conversationItemTime(right) || left.id.localeCompare(right.id))
472
+
473
+ const parseIssueComments = (response: Schema.Schema.Type<typeof CommentsResponseSchema>): readonly PullRequestConversationItem[] =>
474
+ flattenSlurpedPages(response).map(parseIssueComment)
475
+
454
476
  const flattenSlurpedPages = <Item>(response: readonly Item[] | readonly (readonly Item[])[]): readonly Item[] =>
455
477
  Array.isArray(response[0]) ? (response as readonly (readonly Item[])[]).flat() : response as readonly Item[]
456
478
 
@@ -508,6 +530,7 @@ export class GitHubService extends Context.Service<GitHubService, {
508
530
  readonly getAuthenticatedUser: () => Effect.Effect<string, GitHubError>
509
531
  readonly getPullRequestDiff: (repository: string, number: number) => Effect.Effect<string, GitHubError>
510
532
  readonly listPullRequestComments: (repository: string, number: number) => Effect.Effect<readonly PullRequestReviewComment[], GitHubError>
533
+ readonly listPullRequestConversation: (repository: string, number: number) => Effect.Effect<readonly PullRequestConversationItem[], GitHubError>
511
534
  readonly getPullRequestMergeInfo: (repository: string, number: number) => Effect.Effect<PullRequestMergeInfo, GitHubError>
512
535
  readonly mergePullRequest: (repository: string, number: number, action: PullRequestMergeAction) => Effect.Effect<void, CommandError>
513
536
  readonly closePullRequest: (repository: string, number: number) => Effect.Effect<void, CommandError>
@@ -628,6 +651,17 @@ export class GitHubService extends Context.Service<GitHubService, {
628
651
  "api", "--paginate", "--slurp", `repos/${repository}/pulls/${number}/comments`,
629
652
  ]).pipe(Effect.map(parsePullRequestComments))
630
653
 
654
+ const listPullRequestConversation = Effect.fn("GitHubService.listPullRequestConversation")(function*(repository: string, number: number) {
655
+ const [issueComments, reviewComments] = yield* Effect.all([
656
+ ghJson("listPullRequestIssueComments", CommentsResponseSchema, [
657
+ "api", "--paginate", "--slurp", `repos/${repository}/issues/${number}/comments`,
658
+ ]).pipe(Effect.map(parseIssueComments)),
659
+ listPullRequestComments(repository, number).pipe(Effect.map((comments) => comments.map(reviewCommentConversationItem))),
660
+ ], { concurrency: "unbounded" })
661
+
662
+ return sortConversationItems([...issueComments, ...reviewComments])
663
+ })
664
+
631
665
  const getPullRequestMergeInfo = Effect.fn("GitHubService.getPullRequestMergeInfo")(function*(repository: string, number: number) {
632
666
  const info = yield* command.runSchema(MergeInfoResponseSchema, "gh", [
633
667
  "pr", "view", String(number), "--repo", repository,
@@ -663,6 +697,7 @@ export class GitHubService extends Context.Service<GitHubService, {
663
697
  "-f", `path=${input.path}`,
664
698
  "-F", `line=${input.line}`,
665
699
  "-f", `side=${input.side}`,
700
+ ...(input.startLine === undefined ? [] : ["-F", `start_line=${input.startLine}`, "-f", `start_side=${input.startSide ?? input.side}`]),
666
701
  ])
667
702
  return parsePullRequestComment(response) ?? fallbackCreatedComment(input)
668
703
  })
@@ -689,6 +724,7 @@ export class GitHubService extends Context.Service<GitHubService, {
689
724
  getAuthenticatedUser,
690
725
  getPullRequestDiff,
691
726
  listPullRequestComments,
727
+ listPullRequestConversation,
692
728
  getPullRequestMergeInfo,
693
729
  mergePullRequest,
694
730
  closePullRequest,
@@ -1,5 +1,5 @@
1
1
  import { Effect, Layer } from "effect"
2
- import type { CheckItem, CreatePullRequestCommentInput, Mergeable, PullRequestItem, PullRequestLabel, PullRequestMergeInfo, PullRequestPage, PullRequestQueueMode, PullRequestReviewComment, ReviewStatus } from "../domain.js"
2
+ import type { CheckItem, CreatePullRequestCommentInput, Mergeable, PullRequestConversationItem, PullRequestItem, PullRequestLabel, PullRequestMergeInfo, PullRequestPage, PullRequestQueueMode, PullRequestReviewComment, ReviewStatus } from "../domain.js"
3
3
  import { GitHubService } from "./GitHubService.js"
4
4
 
5
5
  export interface MockOptions {
@@ -87,6 +87,19 @@ const pageItems = (source: readonly PullRequestItem[], cursor: string | null, pa
87
87
  }
88
88
  }
89
89
 
90
+ const mockDiff = `diff --git a/src/mockDiff.ts b/src/mockDiff.ts
91
+ --- a/src/mockDiff.ts
92
+ +++ b/src/mockDiff.ts
93
+ @@ -1,6 +1,6 @@
94
+ export const before = true
95
+ -const oldOne = 1
96
+ +const newOne = 1
97
+ - sameName()
98
+ + sameName()
99
+ -const oldTwo = 2
100
+ +const newTwo = 2
101
+ export const after = true`
102
+
90
103
  export const MockGitHubService = {
91
104
  layer: (options: MockOptions) => {
92
105
  const items = buildMockPullRequests(options)
@@ -101,6 +114,27 @@ export const MockGitHubService = {
101
114
  detailLoaded: false,
102
115
  } satisfies PullRequestItem))
103
116
  const findPullRequest = (repository: string, number: number) => items.find((item) => item.repository === repository && item.number === number) ?? items[0]!
117
+ const conversationItems = (repository: string, number: number): readonly PullRequestConversationItem[] => [
118
+ {
119
+ _tag: "comment",
120
+ id: `mock-comment:${repository}:${number}:1`,
121
+ author: "mock-reviewer",
122
+ body: `Top-level discussion for #${number}. This should appear after the summary with its own separator.`,
123
+ createdAt: new Date(Date.now() - 3_600_000),
124
+ url: null,
125
+ },
126
+ {
127
+ _tag: "review-comment",
128
+ id: `mock-review:${repository}:${number}:1`,
129
+ author: "mock-reviewer",
130
+ body: "Inline review comment rendered in the same conversation stream.",
131
+ createdAt: new Date(Date.now() - 1_800_000),
132
+ url: null,
133
+ path: "src/App.tsx",
134
+ line: 42,
135
+ side: "RIGHT",
136
+ },
137
+ ]
104
138
 
105
139
  return Layer.succeed(
106
140
  GitHubService,
@@ -110,8 +144,9 @@ export const MockGitHubService = {
110
144
  listOpenPullRequestDetails: (mode: PullRequestQueueMode, repository: string | null) => Effect.succeed(filterByView(mode, repository, items)),
111
145
  getPullRequestDetails: (repository, number) => Effect.succeed(findPullRequest(repository, number)),
112
146
  getAuthenticatedUser: () => Effect.succeed(username),
113
- getPullRequestDiff: (_repo, _number) => Effect.succeed(""),
147
+ getPullRequestDiff: (_repo, _number) => Effect.succeed(mockDiff),
114
148
  listPullRequestComments: (_repo, _number) => Effect.succeed([] as readonly PullRequestReviewComment[]),
149
+ listPullRequestConversation: (repository, number) => Effect.succeed(conversationItems(repository, number)),
115
150
  getPullRequestMergeInfo: (repository, number) => Effect.succeed({
116
151
  repository,
117
152
  number,
package/src/themeStore.ts CHANGED
@@ -1,11 +1,13 @@
1
1
  import { mkdir } from "node:fs/promises"
2
2
  import { homedir } from "node:os"
3
3
  import { dirname, join } from "node:path"
4
- import { Effect } from "effect"
4
+ import { Effect, Schema } from "effect"
5
5
  import { isThemeId, type ThemeId } from "./ui/colors.js"
6
+ import { DiffWhitespaceMode } from "./ui/diff.js"
6
7
 
7
8
  interface StoredConfig {
8
9
  readonly theme?: unknown
10
+ readonly diffWhitespaceMode?: unknown
9
11
  }
10
12
 
11
13
  const configDirectory = () => {
@@ -22,22 +24,37 @@ const parseConfig = (text: string): StoredConfig => {
22
24
  return value && typeof value === "object" ? value : {}
23
25
  }
24
26
 
25
- export const loadStoredThemeId: Effect.Effect<ThemeId> = Effect.catchCause(Effect.tryPromise(async () => {
27
+ const readStoredConfig = async () => {
26
28
  const file = Bun.file(configPath())
27
- if (!(await file.exists())) return "ghui" satisfies ThemeId
29
+ return await file.exists() ? parseConfig(await file.text()) : {}
30
+ }
28
31
 
29
- const config = parseConfig(await file.text())
32
+ const writeStoredConfig = async (config: StoredConfig) => {
33
+ const path = configPath()
34
+ await mkdir(dirname(path), { recursive: true })
35
+ await Bun.write(path, `${JSON.stringify(config, null, "\t")}\n`)
36
+ }
37
+
38
+ export const loadStoredThemeId: Effect.Effect<ThemeId> = Effect.catchCause(Effect.tryPromise(async () => {
39
+ const config = await readStoredConfig()
30
40
  return isThemeId(config.theme) ? config.theme : "ghui"
31
41
  }), () => Effect.succeed("ghui" satisfies ThemeId))
32
42
 
43
+ export const loadStoredDiffWhitespaceMode: Effect.Effect<DiffWhitespaceMode> = Effect.catchCause(Effect.tryPromise(async () => {
44
+ const config = await readStoredConfig()
45
+ return Schema.is(DiffWhitespaceMode)(config.diffWhitespaceMode) ? config.diffWhitespaceMode : "ignore"
46
+ }), () => Effect.succeed("ignore" satisfies DiffWhitespaceMode))
47
+
33
48
  export const saveStoredThemeId = (theme: ThemeId): Effect.Effect<void> => Effect.tryPromise(async () => {
34
- const path = configPath()
35
- const file = Bun.file(path)
36
- const config = await file.exists()
37
- ? parseConfig(await file.text())
38
- : {}
49
+ const config = await readStoredConfig()
39
50
  if (config.theme === theme) return
40
51
 
41
- await mkdir(dirname(path), { recursive: true })
42
- await Bun.write(path, `${JSON.stringify({ ...config, theme }, null, "\t")}\n`)
52
+ await writeStoredConfig({ ...config, theme })
53
+ })
54
+
55
+ export const saveStoredDiffWhitespaceMode = (diffWhitespaceMode: DiffWhitespaceMode): Effect.Effect<void> => Effect.tryPromise(async () => {
56
+ const config = await readStoredConfig()
57
+ if (config.diffWhitespaceMode === diffWhitespaceMode) return
58
+
59
+ await writeStoredConfig({ ...config, diffWhitespaceMode })
43
60
  })