ikanban-web 0.2.6 → 0.2.8
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/README.md +25 -20
- package/dist/assets/{ghostty-web-BsS8qmby.js → ghostty-web--axxBQj6.js} +1 -1
- package/dist/assets/{home-BVcfU4oW.js → home-BAEIhduF.js} +1 -1
- package/dist/assets/{index-B1DFsnCu.css → index-B0TEjAod.css} +1 -1
- package/dist/assets/{index-BbYqEnGb.js → index-BR3vRMWX.js} +124 -116
- package/dist/assets/session-Bp9iU6SU.js +24 -0
- package/dist/index.html +19 -6
- package/package.json +1 -1
- package/src/components/dialog-session-timeline.tsx +85 -0
- package/src/components/prompt-input.tsx +3 -3
- package/src/components/session/session-header.tsx +2 -211
- package/src/components/settings-general.tsx +18 -93
- package/src/context/global-sync/child-store.ts +1 -0
- package/src/context/global-sync/event-reducer.test.ts +5 -0
- package/src/context/global-sync/event-reducer.ts +263 -183
- package/src/context/global-sync/types.ts +3 -0
- package/src/context/highlights.tsx +4 -176
- package/src/context/server.tsx +24 -4
- package/src/context/sync.tsx +237 -2
- package/src/i18n/en.ts +12 -1
- package/src/i18n/parity.test.ts +4 -22
- package/src/i18n/zh.ts +23 -1
- package/src/pages/layout.tsx +13 -56
- package/src/pages/session/review-tab.tsx +3 -1
- package/src/pages/session/session-side-panel.tsx +17 -51
- package/src/pages/session/use-session-commands.tsx +91 -154
- package/src/pages/session.tsx +87 -16
- package/dist/assets/session-CZ-wC_3g.js +0 -24
- package/src/i18n/ar.ts +0 -751
- package/src/i18n/br.ts +0 -759
- package/src/i18n/bs.ts +0 -836
- package/src/i18n/da.ts +0 -830
- package/src/i18n/de.ts +0 -768
- package/src/i18n/es.ts +0 -842
- package/src/i18n/fr.ts +0 -766
- package/src/i18n/ja.ts +0 -755
- package/src/i18n/ko.ts +0 -755
- package/src/i18n/no.ts +0 -838
- package/src/i18n/pl.ts +0 -757
- package/src/i18n/ru.ts +0 -838
- package/src/i18n/th.ts +0 -828
- package/src/i18n/tr.ts +0 -849
- package/src/i18n/zht.ts +0 -821
|
@@ -1,208 +1,36 @@
|
|
|
1
|
-
import { createEffect, createSignal
|
|
1
|
+
import { createEffect, createSignal } from "solid-js"
|
|
2
2
|
import { createStore } from "solid-js/store"
|
|
3
3
|
import { createSimpleContext } from "ikanban-ui/context"
|
|
4
|
-
import { useDialog } from "ikanban-ui/context/dialog"
|
|
5
4
|
import { usePlatform } from "@/context/platform"
|
|
6
|
-
import { useSettings } from "@/context/settings"
|
|
7
5
|
import { persisted } from "@/utils/persist"
|
|
8
|
-
import { DialogReleaseNotes, type Highlight } from "@/components/dialog-release-notes"
|
|
9
|
-
|
|
10
|
-
const CHANGELOG_URL = "https://opencode.ai/changelog.json"
|
|
11
6
|
|
|
12
7
|
type Store = {
|
|
13
8
|
version?: string
|
|
14
9
|
}
|
|
15
10
|
|
|
16
|
-
type ParsedRelease = {
|
|
17
|
-
tag?: string
|
|
18
|
-
highlights: Highlight[]
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
22
|
-
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
function getText(value: unknown): string | undefined {
|
|
26
|
-
if (typeof value === "string") {
|
|
27
|
-
const text = value.trim()
|
|
28
|
-
return text.length > 0 ? text : undefined
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
if (typeof value === "number") return String(value)
|
|
32
|
-
return
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function normalizeVersion(value: string | undefined) {
|
|
36
|
-
const text = value?.trim()
|
|
37
|
-
if (!text) return
|
|
38
|
-
return text.startsWith("v") || text.startsWith("V") ? text.slice(1) : text
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function parseMedia(value: unknown, alt: string): Highlight["media"] | undefined {
|
|
42
|
-
if (!isRecord(value)) return
|
|
43
|
-
const type = getText(value.type)?.toLowerCase()
|
|
44
|
-
const src = getText(value.src) ?? getText(value.url)
|
|
45
|
-
if (!src) return
|
|
46
|
-
if (type !== "image" && type !== "video") return
|
|
47
|
-
|
|
48
|
-
return { type, src, alt }
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function parseHighlight(value: unknown): Highlight | undefined {
|
|
52
|
-
if (!isRecord(value)) return
|
|
53
|
-
|
|
54
|
-
const title = getText(value.title)
|
|
55
|
-
if (!title) return
|
|
56
|
-
|
|
57
|
-
const description = getText(value.description) ?? getText(value.shortDescription)
|
|
58
|
-
if (!description) return
|
|
59
|
-
|
|
60
|
-
const media = parseMedia(value.media, title)
|
|
61
|
-
return { title, description, media }
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
function parseRelease(value: unknown): ParsedRelease | undefined {
|
|
65
|
-
if (!isRecord(value)) return
|
|
66
|
-
const tag = getText(value.tag) ?? getText(value.tag_name) ?? getText(value.name)
|
|
67
|
-
|
|
68
|
-
if (!Array.isArray(value.highlights)) {
|
|
69
|
-
return { tag, highlights: [] }
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
const highlights = value.highlights.flatMap((group) => {
|
|
73
|
-
if (!isRecord(group)) return []
|
|
74
|
-
|
|
75
|
-
const source = getText(group.source)
|
|
76
|
-
if (!source) return []
|
|
77
|
-
if (!source.toLowerCase().includes("desktop")) return []
|
|
78
|
-
|
|
79
|
-
if (Array.isArray(group.items)) {
|
|
80
|
-
return group.items.map((item) => parseHighlight(item)).filter((item): item is Highlight => item !== undefined)
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
const item = parseHighlight(group)
|
|
84
|
-
if (!item) return []
|
|
85
|
-
return [item]
|
|
86
|
-
})
|
|
87
|
-
|
|
88
|
-
return { tag, highlights }
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
function parseChangelog(value: unknown): ParsedRelease[] | undefined {
|
|
92
|
-
if (Array.isArray(value)) {
|
|
93
|
-
return value.map(parseRelease).filter((release): release is ParsedRelease => release !== undefined)
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
if (!isRecord(value)) return
|
|
97
|
-
if (!Array.isArray(value.releases)) return
|
|
98
|
-
|
|
99
|
-
return value.releases.map(parseRelease).filter((release): release is ParsedRelease => release !== undefined)
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
function sliceHighlights(input: { releases: ParsedRelease[]; current?: string; previous?: string }) {
|
|
103
|
-
const current = normalizeVersion(input.current)
|
|
104
|
-
const previous = normalizeVersion(input.previous)
|
|
105
|
-
const releases = input.releases
|
|
106
|
-
|
|
107
|
-
const start = (() => {
|
|
108
|
-
if (!current) return 0
|
|
109
|
-
const index = releases.findIndex((release) => normalizeVersion(release.tag) === current)
|
|
110
|
-
return index === -1 ? 0 : index
|
|
111
|
-
})()
|
|
112
|
-
|
|
113
|
-
const end = (() => {
|
|
114
|
-
if (!previous) return releases.length
|
|
115
|
-
const index = releases.findIndex((release, i) => i >= start && normalizeVersion(release.tag) === previous)
|
|
116
|
-
return index === -1 ? releases.length : index
|
|
117
|
-
})()
|
|
118
|
-
|
|
119
|
-
const highlights = releases.slice(start, end).flatMap((release) => release.highlights)
|
|
120
|
-
const seen = new Set<string>()
|
|
121
|
-
const unique = highlights.filter((highlight) => {
|
|
122
|
-
const key = dedupeKey(highlight)
|
|
123
|
-
if (seen.has(key)) return false
|
|
124
|
-
seen.add(key)
|
|
125
|
-
return true
|
|
126
|
-
})
|
|
127
|
-
return unique.slice(0, 5)
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
function dedupeKey(highlight: Highlight) {
|
|
131
|
-
return [highlight.title, highlight.description, highlight.media?.type ?? "", highlight.media?.src ?? ""].join("\n")
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
function loadReleaseHighlights(value: unknown, current?: string, previous?: string) {
|
|
135
|
-
const releases = parseChangelog(value)
|
|
136
|
-
if (!releases?.length) return []
|
|
137
|
-
return sliceHighlights({ releases, current, previous })
|
|
138
|
-
}
|
|
139
|
-
|
|
140
11
|
export const { use: useHighlights, provider: HighlightsProvider } = createSimpleContext({
|
|
141
12
|
name: "Highlights",
|
|
142
13
|
gate: false,
|
|
143
14
|
init: () => {
|
|
144
15
|
const platform = usePlatform()
|
|
145
|
-
const dialog = useDialog()
|
|
146
|
-
const settings = useSettings()
|
|
147
16
|
const [store, setStore, _, ready] = persisted("highlights.v1", createStore<Store>({ version: undefined }))
|
|
148
17
|
|
|
149
18
|
const [from, setFrom] = createSignal<string | undefined>(undefined)
|
|
150
19
|
const [to, setTo] = createSignal<string | undefined>(undefined)
|
|
151
20
|
const state = { started: false }
|
|
152
|
-
let timer: ReturnType<typeof setTimeout> | undefined
|
|
153
|
-
|
|
154
|
-
const clearTimer = () => {
|
|
155
|
-
if (timer === undefined) return
|
|
156
|
-
clearTimeout(timer)
|
|
157
|
-
timer = undefined
|
|
158
|
-
}
|
|
159
|
-
|
|
160
21
|
const markSeen = () => {
|
|
161
22
|
if (!platform.version) return
|
|
162
23
|
setStore("version", platform.version)
|
|
163
24
|
}
|
|
164
25
|
|
|
165
|
-
const start = (
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
return
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
const fetcher = platform.fetch ?? fetch
|
|
172
|
-
const controller = new AbortController()
|
|
173
|
-
onCleanup(() => {
|
|
174
|
-
controller.abort()
|
|
175
|
-
clearTimer()
|
|
176
|
-
})
|
|
177
|
-
|
|
178
|
-
fetcher(CHANGELOG_URL, {
|
|
179
|
-
signal: controller.signal,
|
|
180
|
-
headers: { Accept: "application/json" },
|
|
181
|
-
})
|
|
182
|
-
.then((response) => (response.ok ? (response.json() as Promise<unknown>) : undefined))
|
|
183
|
-
.then((json) => {
|
|
184
|
-
if (!json) return
|
|
185
|
-
const highlights = loadReleaseHighlights(json, platform.version, previous)
|
|
186
|
-
if (controller.signal.aborted) return
|
|
187
|
-
|
|
188
|
-
if (highlights.length === 0) {
|
|
189
|
-
markSeen()
|
|
190
|
-
return
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
timer = setTimeout(() => {
|
|
194
|
-
timer = undefined
|
|
195
|
-
markSeen()
|
|
196
|
-
dialog.show(() => <DialogReleaseNotes highlights={highlights} />)
|
|
197
|
-
}, 500)
|
|
198
|
-
})
|
|
199
|
-
.catch(() => undefined)
|
|
26
|
+
const start = (_previous: string) => {
|
|
27
|
+
// Updates and release-note prompts are temporarily disabled.
|
|
28
|
+
markSeen()
|
|
200
29
|
}
|
|
201
30
|
|
|
202
31
|
createEffect(() => {
|
|
203
32
|
if (state.started) return
|
|
204
33
|
if (!ready()) return
|
|
205
|
-
if (!settings.ready()) return
|
|
206
34
|
if (!platform.version) return
|
|
207
35
|
state.started = true
|
|
208
36
|
|
package/src/context/server.tsx
CHANGED
|
@@ -102,6 +102,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
|
|
|
102
102
|
Persist.global("server", ["server.v3"]),
|
|
103
103
|
createStore({
|
|
104
104
|
list: [] as StoredServer[],
|
|
105
|
+
deleted: [] as ServerConnection.Key[],
|
|
105
106
|
projects: {} as Record<string, StoredProject[]>,
|
|
106
107
|
lastProject: {} as Record<string, string>,
|
|
107
108
|
}),
|
|
@@ -110,8 +111,9 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
|
|
|
110
111
|
const url = (x: StoredServer) => (typeof x === "string" ? x : "type" in x ? x.http.url : x.url)
|
|
111
112
|
|
|
112
113
|
const allServers = createMemo((): Array<ServerConnection.Any> => {
|
|
114
|
+
const deletedSet = new Set(store.deleted)
|
|
113
115
|
const servers = [
|
|
114
|
-
...(props.servers ?? []),
|
|
116
|
+
...(props.servers ?? []).filter((s) => !deletedSet.has(ServerConnection.key(s))),
|
|
115
117
|
...store.list.map((value) =>
|
|
116
118
|
typeof value === "string"
|
|
117
119
|
? {
|
|
@@ -172,6 +174,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
|
|
|
172
174
|
const url_ = normalizeServerUrl(input.http.url)
|
|
173
175
|
if (!url_) return
|
|
174
176
|
const conn = { ...input, http: { ...input.http, url: url_ } }
|
|
177
|
+
const connKey = ServerConnection.key(conn)
|
|
175
178
|
return batch(() => {
|
|
176
179
|
const existing = store.list.findIndex((x) => url(x) === url_)
|
|
177
180
|
if (existing !== -1) {
|
|
@@ -179,18 +182,35 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
|
|
|
179
182
|
} else {
|
|
180
183
|
setStore("list", store.list.length, conn)
|
|
181
184
|
}
|
|
182
|
-
|
|
185
|
+
// If this server was previously deleted, un-delete it
|
|
186
|
+
if (store.deleted.includes(connKey)) {
|
|
187
|
+
setStore("deleted", store.deleted.filter((k) => k !== connKey))
|
|
188
|
+
}
|
|
189
|
+
setState("active", connKey)
|
|
183
190
|
return conn
|
|
184
191
|
})
|
|
185
192
|
}
|
|
186
193
|
|
|
187
194
|
function remove(key: ServerConnection.Key) {
|
|
188
195
|
const list = store.list.filter((x) => url(x) !== key)
|
|
196
|
+
const isPropServer = (props.servers ?? []).some((s) => ServerConnection.key(s) === key)
|
|
189
197
|
batch(() => {
|
|
190
198
|
setStore("list", list)
|
|
199
|
+
// If it's a prop-injected server, record it as deleted so it stays gone after reload
|
|
200
|
+
if (isPropServer && !store.deleted.includes(key)) {
|
|
201
|
+
setStore("deleted", [...store.deleted, key])
|
|
202
|
+
}
|
|
191
203
|
if (state.active === key) {
|
|
192
|
-
const
|
|
193
|
-
|
|
204
|
+
const nextStored = list[0]
|
|
205
|
+
const nextProp = (props.servers ?? []).find(
|
|
206
|
+
(s) => !store.deleted.includes(ServerConnection.key(s)) && ServerConnection.key(s) !== key,
|
|
207
|
+
)
|
|
208
|
+
const nextKey = nextStored
|
|
209
|
+
? ServerConnection.Key.make(url(nextStored))
|
|
210
|
+
: nextProp
|
|
211
|
+
? ServerConnection.key(nextProp)
|
|
212
|
+
: props.defaultServer
|
|
213
|
+
setState("active", nextKey)
|
|
194
214
|
}
|
|
195
215
|
})
|
|
196
216
|
}
|
package/src/context/sync.tsx
CHANGED
|
@@ -1,11 +1,180 @@
|
|
|
1
1
|
import { batch, createMemo } from "solid-js"
|
|
2
|
-
import { createStore, produce, reconcile } from "solid-js/store"
|
|
2
|
+
import { createStore, produce, reconcile, type SetStoreFunction } from "solid-js/store"
|
|
3
3
|
import { Binary } from "ikanban-utils/binary"
|
|
4
4
|
import { retry } from "ikanban-utils/retry"
|
|
5
5
|
import { createSimpleContext } from "ikanban-ui/context"
|
|
6
|
+
import { applyPatch, parsePatch, reversePatch, type StructuredPatch } from "diff"
|
|
6
7
|
import { useGlobalSync } from "./global-sync"
|
|
7
8
|
import { useSDK } from "./sdk"
|
|
8
|
-
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
|
|
9
|
+
import type { File as SDKFile, FileContent, FileDiff, Message, Part } from "@opencode-ai/sdk/v2/client"
|
|
10
|
+
|
|
11
|
+
type ProjectDiffEntry = FileDiff & {
|
|
12
|
+
lazy?: boolean
|
|
13
|
+
loading?: boolean
|
|
14
|
+
binary?: boolean
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const PROJECT_DIFF_INITIAL_READ_LIMIT = 20
|
|
18
|
+
const PROJECT_DIFF_READ_CONCURRENCY = 4
|
|
19
|
+
const PROJECT_DIFF_MAX_EAGER_CHANGED_LINES = 400
|
|
20
|
+
const BINARY_FILE_RE = /\.(?:png|jpe?g|gif|webp|bmp|ico|avif|mp3|wav|ogg|flac|mp4|mov|avi|mkv|webm|pdf|zip|gz|tar|7z|woff2?|ttf|eot|otf|exe|bin|so|dylib|dll|class|jar|wasm)$/i
|
|
21
|
+
|
|
22
|
+
function isLikelyBinaryPath(path: string) {
|
|
23
|
+
return BINARY_FILE_RE.test(path)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function createProjectPlaceholder(file: SDKFile, input: { lazy?: boolean; loading?: boolean; binary?: boolean } = {}): ProjectDiffEntry {
|
|
27
|
+
return {
|
|
28
|
+
file: file.path,
|
|
29
|
+
before: "",
|
|
30
|
+
after: "",
|
|
31
|
+
additions: file.added,
|
|
32
|
+
deletions: file.removed,
|
|
33
|
+
status: file.status,
|
|
34
|
+
lazy: input.lazy,
|
|
35
|
+
loading: input.loading,
|
|
36
|
+
binary: input.binary,
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function shouldEagerLoadProjectDiff(file: SDKFile) {
|
|
41
|
+
if (isLikelyBinaryPath(file.path)) return false
|
|
42
|
+
if (file.added + file.removed > PROJECT_DIFF_MAX_EAGER_CHANGED_LINES) return false
|
|
43
|
+
return true
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function getStructuredPatch(content?: FileContent): StructuredPatch | undefined {
|
|
47
|
+
if (!content) return
|
|
48
|
+
if (content.diff) {
|
|
49
|
+
const parsed = parsePatch(content.diff)[0]
|
|
50
|
+
if (parsed) return parsed
|
|
51
|
+
}
|
|
52
|
+
if (!content.patch) return
|
|
53
|
+
return {
|
|
54
|
+
oldFileName: content.patch.oldFileName,
|
|
55
|
+
newFileName: content.patch.newFileName,
|
|
56
|
+
oldHeader: content.patch.oldHeader,
|
|
57
|
+
newHeader: content.patch.newHeader,
|
|
58
|
+
hunks: content.patch.hunks.map((hunk) => ({
|
|
59
|
+
oldStart: hunk.oldStart,
|
|
60
|
+
oldLines: hunk.oldLines,
|
|
61
|
+
newStart: hunk.newStart,
|
|
62
|
+
newLines: hunk.newLines,
|
|
63
|
+
lines: [...hunk.lines],
|
|
64
|
+
})),
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function buildWorkspaceDiff(file: SDKFile, content?: FileContent): ProjectDiffEntry | undefined {
|
|
69
|
+
const patch = getStructuredPatch(content)
|
|
70
|
+
|
|
71
|
+
if (content?.type === "binary") {
|
|
72
|
+
return createProjectPlaceholder(file, { binary: true })
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (file.status === "added") {
|
|
76
|
+
const after = content?.content ?? (patch ? applyPatch("", patch) || "" : "")
|
|
77
|
+
return {
|
|
78
|
+
file: file.path,
|
|
79
|
+
before: "",
|
|
80
|
+
after,
|
|
81
|
+
additions: file.added,
|
|
82
|
+
deletions: file.removed,
|
|
83
|
+
status: file.status,
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (file.status === "deleted") {
|
|
88
|
+
const before = content?.content ?? (patch ? applyPatch("", reversePatch(patch)) || "" : "")
|
|
89
|
+
return {
|
|
90
|
+
file: file.path,
|
|
91
|
+
before,
|
|
92
|
+
after: "",
|
|
93
|
+
additions: file.added,
|
|
94
|
+
deletions: file.removed,
|
|
95
|
+
status: file.status,
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const after = content?.content ?? ""
|
|
100
|
+
const before = patch ? applyPatch(after, reversePatch(patch)) || after : after
|
|
101
|
+
|
|
102
|
+
if (!patch && file.added + file.removed === 0) return
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
file: file.path,
|
|
106
|
+
before,
|
|
107
|
+
after,
|
|
108
|
+
additions: file.added,
|
|
109
|
+
deletions: file.removed,
|
|
110
|
+
status: file.status,
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function fetchProjectDiffs(client: ReturnType<typeof useSDK>["client"], directory: string): Promise<FileDiff[]> {
|
|
115
|
+
const files = await retry(() => client.file.status({ directory })).then((result) => result.data ?? [])
|
|
116
|
+
return files.map((file) =>
|
|
117
|
+
createProjectPlaceholder(file, {
|
|
118
|
+
lazy: !shouldEagerLoadProjectDiff(file),
|
|
119
|
+
loading: false,
|
|
120
|
+
}),
|
|
121
|
+
)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function hydrateProjectDiffFile(
|
|
125
|
+
client: ReturnType<typeof useSDK>["client"],
|
|
126
|
+
directory: string,
|
|
127
|
+
file: SDKFile,
|
|
128
|
+
): Promise<ProjectDiffEntry> {
|
|
129
|
+
const content = await retry(() => client.file.read({ path: file.path, directory }))
|
|
130
|
+
.then((result) => result.data)
|
|
131
|
+
.catch(() => undefined)
|
|
132
|
+
return buildWorkspaceDiff(file, content) ?? createProjectPlaceholder(file)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function updateProjectDiffEntry(
|
|
136
|
+
setStore: SetStoreFunction<any>,
|
|
137
|
+
directory: string,
|
|
138
|
+
path: string,
|
|
139
|
+
updater: (current: ProjectDiffEntry) => ProjectDiffEntry,
|
|
140
|
+
) {
|
|
141
|
+
setStore(
|
|
142
|
+
"project_diff",
|
|
143
|
+
directory,
|
|
144
|
+
produce((draft: ProjectDiffEntry[]) => {
|
|
145
|
+
const index = draft.findIndex((item) => item.file === path)
|
|
146
|
+
if (index === -1) return
|
|
147
|
+
draft[index] = updater(draft[index])
|
|
148
|
+
}),
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function hydrateProjectDiffBatch(input: {
|
|
153
|
+
client: ReturnType<typeof useSDK>["client"]
|
|
154
|
+
directory: string
|
|
155
|
+
setStore: SetStoreFunction<any>
|
|
156
|
+
entries: ProjectDiffEntry[]
|
|
157
|
+
}) {
|
|
158
|
+
for (let i = 0; i < input.entries.length; i += PROJECT_DIFF_READ_CONCURRENCY) {
|
|
159
|
+
const chunk = input.entries.slice(i, i + PROJECT_DIFF_READ_CONCURRENCY)
|
|
160
|
+
await Promise.all(
|
|
161
|
+
chunk.map(async (entry) => {
|
|
162
|
+
const file: SDKFile = {
|
|
163
|
+
path: entry.file,
|
|
164
|
+
added: entry.additions,
|
|
165
|
+
removed: entry.deletions,
|
|
166
|
+
status: entry.status ?? "modified",
|
|
167
|
+
}
|
|
168
|
+
const hydrated = await hydrateProjectDiffFile(input.client, input.directory, file)
|
|
169
|
+
updateProjectDiffEntry(input.setStore, input.directory, entry.file, () => ({
|
|
170
|
+
...hydrated,
|
|
171
|
+
lazy: false,
|
|
172
|
+
loading: false,
|
|
173
|
+
}))
|
|
174
|
+
}),
|
|
175
|
+
)
|
|
176
|
+
}
|
|
177
|
+
}
|
|
9
178
|
|
|
10
179
|
function sortParts(parts: Part[]) {
|
|
11
180
|
return parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id))
|
|
@@ -107,6 +276,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|
|
107
276
|
const messagePageSize = 200
|
|
108
277
|
const inflight = new Map<string, Promise<void>>()
|
|
109
278
|
const inflightDiff = new Map<string, Promise<void>>()
|
|
279
|
+
const inflightProjectFile = new Map<string, Promise<void>>()
|
|
110
280
|
const inflightTodo = new Map<string, Promise<void>>()
|
|
111
281
|
const [meta, setMeta] = createStore({
|
|
112
282
|
limit: {} as Record<string, number>,
|
|
@@ -350,6 +520,71 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|
|
350
520
|
)
|
|
351
521
|
},
|
|
352
522
|
},
|
|
523
|
+
projectDiff: {
|
|
524
|
+
async diff() {
|
|
525
|
+
const directory = sdk.directory
|
|
526
|
+
const client = sdk.client
|
|
527
|
+
const [store, setStore] = globalSync.child(directory)
|
|
528
|
+
if (store.project_diff[directory] !== undefined) return
|
|
529
|
+
|
|
530
|
+
const key = `project\n${directory}`
|
|
531
|
+
return runInflight(inflightDiff, key, async () => {
|
|
532
|
+
const diff = (await fetchProjectDiffs(client, directory)) as ProjectDiffEntry[]
|
|
533
|
+
const eagerFiles = new Set(
|
|
534
|
+
diff
|
|
535
|
+
.filter((entry) => !entry.lazy)
|
|
536
|
+
.slice(0, PROJECT_DIFF_INITIAL_READ_LIMIT)
|
|
537
|
+
.map((entry) => entry.file),
|
|
538
|
+
)
|
|
539
|
+
const seeded = diff.map((entry) => ({
|
|
540
|
+
...entry,
|
|
541
|
+
lazy: entry.lazy || !eagerFiles.has(entry.file),
|
|
542
|
+
loading: eagerFiles.has(entry.file),
|
|
543
|
+
}))
|
|
544
|
+
setStore("project_diff", directory, reconcile(seeded, { key: "file" }))
|
|
545
|
+
|
|
546
|
+
const eager = seeded.filter((entry) => eagerFiles.has(entry.file))
|
|
547
|
+
|
|
548
|
+
if (!eager.length) return
|
|
549
|
+
|
|
550
|
+
await hydrateProjectDiffBatch({
|
|
551
|
+
client,
|
|
552
|
+
directory,
|
|
553
|
+
setStore,
|
|
554
|
+
entries: eager,
|
|
555
|
+
})
|
|
556
|
+
})
|
|
557
|
+
},
|
|
558
|
+
hydrate(path: string) {
|
|
559
|
+
const directory = sdk.directory
|
|
560
|
+
const client = sdk.client
|
|
561
|
+
const [store, setStore] = globalSync.child(directory)
|
|
562
|
+
const entries = store.project_diff[directory] as ProjectDiffEntry[] | undefined
|
|
563
|
+
const existing = entries?.find((item) => item.file === path)
|
|
564
|
+
if (!existing || (!existing.lazy && !existing.loading)) return
|
|
565
|
+
|
|
566
|
+
const fileKey = `project-file\n${directory}\n${path}`
|
|
567
|
+
return runInflight(inflightProjectFile, fileKey, async () => {
|
|
568
|
+
updateProjectDiffEntry(setStore, directory, path, (current) => ({
|
|
569
|
+
...current,
|
|
570
|
+
loading: true,
|
|
571
|
+
}))
|
|
572
|
+
|
|
573
|
+
const file: SDKFile = {
|
|
574
|
+
path: existing.file,
|
|
575
|
+
added: existing.additions,
|
|
576
|
+
removed: existing.deletions,
|
|
577
|
+
status: existing.status ?? "modified",
|
|
578
|
+
}
|
|
579
|
+
const hydrated = await hydrateProjectDiffFile(client, directory, file)
|
|
580
|
+
updateProjectDiffEntry(setStore, directory, path, () => ({
|
|
581
|
+
...hydrated,
|
|
582
|
+
lazy: false,
|
|
583
|
+
loading: false,
|
|
584
|
+
}))
|
|
585
|
+
})
|
|
586
|
+
},
|
|
587
|
+
},
|
|
353
588
|
absolute,
|
|
354
589
|
get directory() {
|
|
355
590
|
return current()[0].path.directory
|
package/src/i18n/en.ts
CHANGED
|
@@ -23,6 +23,7 @@ export const dict = {
|
|
|
23
23
|
|
|
24
24
|
"command.sidebar.toggle": "Toggle sidebar",
|
|
25
25
|
"command.project.open": "Open project",
|
|
26
|
+
"command.project.close": "Close project",
|
|
26
27
|
"command.provider.connect": "Connect provider",
|
|
27
28
|
"command.server.switch": "Switch server",
|
|
28
29
|
"command.settings.open": "Open settings",
|
|
@@ -77,6 +78,8 @@ export const dict = {
|
|
|
77
78
|
"command.workspace.toggle.description": "Enable or disable multiple workspaces in the sidebar",
|
|
78
79
|
"command.session.undo": "Undo",
|
|
79
80
|
"command.session.undo.description": "Undo the last message",
|
|
81
|
+
"command.session.timeline": "Timeline",
|
|
82
|
+
"command.session.timeline.description": "Jump to any message in this session timeline",
|
|
80
83
|
"command.session.redo": "Redo",
|
|
81
84
|
"command.session.redo.description": "Redo the last undone message",
|
|
82
85
|
"command.session.compact": "Compact session",
|
|
@@ -303,6 +306,8 @@ export const dict = {
|
|
|
303
306
|
"mcp.status.disabled": "disabled",
|
|
304
307
|
|
|
305
308
|
"dialog.fork.empty": "No messages to fork from",
|
|
309
|
+
"dialog.timeline.empty": "No messages in this timeline",
|
|
310
|
+
"dialog.timeline.latest": "Latest message",
|
|
306
311
|
|
|
307
312
|
"dialog.directory.search.placeholder": "Search folders",
|
|
308
313
|
"dialog.directory.empty": "No folders found",
|
|
@@ -513,6 +518,8 @@ export const dict = {
|
|
|
513
518
|
"session.review.loadingChanges": "Loading changes...",
|
|
514
519
|
"session.review.empty": "No changes in this session yet",
|
|
515
520
|
"session.review.noVcs": "No git VCS detected, so session changes will not be detected",
|
|
521
|
+
"session.review.emptyProject": "No project changes yet",
|
|
522
|
+
"session.review.noVcsProject": "No git VCS detected, so project changes will not be detected",
|
|
516
523
|
"session.review.noChanges": "No changes",
|
|
517
524
|
|
|
518
525
|
"session.files.selectToOpen": "Select a file to open",
|
|
@@ -610,7 +617,7 @@ export const dict = {
|
|
|
610
617
|
"sidebar.project.viewAllSessions": "View all sessions",
|
|
611
618
|
"sidebar.project.clearNotifications": "Clear notifications",
|
|
612
619
|
|
|
613
|
-
"app.name.desktop": "
|
|
620
|
+
"app.name.desktop": "iKanban",
|
|
614
621
|
|
|
615
622
|
"settings.section.desktop": "Desktop",
|
|
616
623
|
"settings.section.server": "Server",
|
|
@@ -624,6 +631,7 @@ export const dict = {
|
|
|
624
631
|
"settings.general.section.notifications": "System notifications",
|
|
625
632
|
"settings.general.section.updates": "Updates",
|
|
626
633
|
"settings.general.section.sounds": "Sound effects",
|
|
634
|
+
"settings.general.section.releaseNotes": "Release notes",
|
|
627
635
|
"settings.general.section.feed": "Feed",
|
|
628
636
|
"settings.general.section.display": "Display",
|
|
629
637
|
|
|
@@ -651,6 +659,9 @@ export const dict = {
|
|
|
651
659
|
|
|
652
660
|
"settings.general.row.releaseNotes.title": "Release notes",
|
|
653
661
|
"settings.general.row.releaseNotes.description": "Show What's New popups after updates",
|
|
662
|
+
"settings.general.row.releaseNotesCurrent.title": "Current version changes",
|
|
663
|
+
"settings.general.row.releaseNotesCurrent.description": "Open this version's changelog entries on GitHub",
|
|
664
|
+
"settings.general.action.releaseNotesCurrent": "View changes",
|
|
654
665
|
|
|
655
666
|
"settings.updates.row.startup.title": "Check for updates on startup",
|
|
656
667
|
"settings.updates.row.startup.description": "Automatically check for updates when OpenCode launches",
|
package/src/i18n/parity.test.ts
CHANGED
|
@@ -1,32 +1,14 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test"
|
|
2
2
|
import { dict as en } from "./en"
|
|
3
|
-
import { dict as ar } from "./ar"
|
|
4
|
-
import { dict as br } from "./br"
|
|
5
|
-
import { dict as bs } from "./bs"
|
|
6
|
-
import { dict as da } from "./da"
|
|
7
|
-
import { dict as de } from "./de"
|
|
8
|
-
import { dict as es } from "./es"
|
|
9
|
-
import { dict as fr } from "./fr"
|
|
10
|
-
import { dict as ja } from "./ja"
|
|
11
|
-
import { dict as ko } from "./ko"
|
|
12
|
-
import { dict as no } from "./no"
|
|
13
|
-
import { dict as pl } from "./pl"
|
|
14
|
-
import { dict as ru } from "./ru"
|
|
15
|
-
import { dict as th } from "./th"
|
|
16
3
|
import { dict as zh } from "./zh"
|
|
17
|
-
import { dict as zht } from "./zht"
|
|
18
|
-
import { dict as tr } from "./tr"
|
|
19
4
|
|
|
20
|
-
const locales = [ar, br, bs, da, de, es, fr, ja, ko, no, pl, ru, th, tr, zh, zht]
|
|
21
5
|
const keys = ["command.session.previous.unseen", "command.session.next.unseen"] as const
|
|
22
6
|
|
|
23
7
|
describe("i18n parity", () => {
|
|
24
|
-
test("
|
|
25
|
-
for (const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
expect(locale[key]).not.toBe(en[key])
|
|
29
|
-
}
|
|
8
|
+
test("zh translates targeted unseen session keys", () => {
|
|
9
|
+
for (const key of keys) {
|
|
10
|
+
expect(zh[key]).toBeDefined()
|
|
11
|
+
expect(zh[key]).not.toBe(en[key])
|
|
30
12
|
}
|
|
31
13
|
})
|
|
32
14
|
})
|