feat(project): add icon_url_override field to projects (#23955)
This commit is contained in:
parent
785f3589ab
commit
6002500bc0
@ -10,9 +10,8 @@ import type {
|
|||||||
import { showToast } from "@opencode-ai/ui/toast"
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
import { getFilename } from "@opencode-ai/shared/util/path"
|
import { getFilename } from "@opencode-ai/shared/util/path"
|
||||||
import { batch, createContext, getOwner, onCleanup, onMount, type ParentProps, untrack, useContext } from "solid-js"
|
import { batch, createContext, getOwner, onCleanup, onMount, type ParentProps, untrack, useContext } from "solid-js"
|
||||||
import { createStore, produce, reconcile, unwrap } from "solid-js/store"
|
import { createStore, produce, reconcile } from "solid-js/store"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
|
||||||
import type { InitError } from "../pages/error"
|
import type { InitError } from "../pages/error"
|
||||||
import { useGlobalSDK } from "./global-sdk"
|
import { useGlobalSDK } from "./global-sdk"
|
||||||
import { bootstrapDirectory, bootstrapGlobal, clearProviderRev } from "./global-sync/bootstrap"
|
import { bootstrapDirectory, bootstrapGlobal, clearProviderRev } from "./global-sync/bootstrap"
|
||||||
@ -24,7 +23,6 @@ import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global
|
|||||||
import { trimSessions } from "./global-sync/session-trim"
|
import { trimSessions } from "./global-sync/session-trim"
|
||||||
import type { ProjectMeta } from "./global-sync/types"
|
import type { ProjectMeta } from "./global-sync/types"
|
||||||
import { SESSION_RECENT_LIMIT } from "./global-sync/types"
|
import { SESSION_RECENT_LIMIT } from "./global-sync/types"
|
||||||
import { sanitizeProject } from "./global-sync/utils"
|
|
||||||
import { formatServerError } from "@/utils/server-errors"
|
import { formatServerError } from "@/utils/server-errors"
|
||||||
import { queryOptions, skipToken, useQueryClient } from "@tanstack/solid-query"
|
import { queryOptions, skipToken, useQueryClient } from "@tanstack/solid-query"
|
||||||
|
|
||||||
@ -56,15 +54,10 @@ function createGlobalSync() {
|
|||||||
const sessionLoads = new Map<string, Promise<void>>()
|
const sessionLoads = new Map<string, Promise<void>>()
|
||||||
const sessionMeta = new Map<string, { limit: number }>()
|
const sessionMeta = new Map<string, { limit: number }>()
|
||||||
|
|
||||||
const [projectCache, setProjectCache, projectInit] = persisted(
|
|
||||||
Persist.global("globalSync.project", ["globalSync.project.v1"]),
|
|
||||||
createStore({ value: [] as Project[] }),
|
|
||||||
)
|
|
||||||
|
|
||||||
const [globalStore, setGlobalStore] = createStore<GlobalStore>({
|
const [globalStore, setGlobalStore] = createStore<GlobalStore>({
|
||||||
ready: false,
|
ready: false,
|
||||||
path: { state: "", config: "", worktree: "", directory: "", home: "" },
|
path: { state: "", config: "", worktree: "", directory: "", home: "" },
|
||||||
project: projectCache.value,
|
project: [],
|
||||||
session_todo: {},
|
session_todo: {},
|
||||||
provider: { all: [], connected: [], default: {} },
|
provider: { all: [], connected: [], default: {} },
|
||||||
provider_auth: {},
|
provider_auth: {},
|
||||||
@ -73,32 +66,18 @@ function createGlobalSync() {
|
|||||||
})
|
})
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
let active = true
|
|
||||||
let projectWritten = false
|
|
||||||
let bootedAt = 0
|
let bootedAt = 0
|
||||||
let bootingRoot = false
|
let bootingRoot = false
|
||||||
let eventFrame: number | undefined
|
let eventFrame: number | undefined
|
||||||
let eventTimer: ReturnType<typeof setTimeout> | undefined
|
let eventTimer: ReturnType<typeof setTimeout> | undefined
|
||||||
|
|
||||||
onCleanup(() => {
|
|
||||||
active = false
|
|
||||||
})
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
if (eventFrame !== undefined) cancelAnimationFrame(eventFrame)
|
if (eventFrame !== undefined) cancelAnimationFrame(eventFrame)
|
||||||
if (eventTimer !== undefined) clearTimeout(eventTimer)
|
if (eventTimer !== undefined) clearTimeout(eventTimer)
|
||||||
})
|
})
|
||||||
|
|
||||||
const cacheProjects = () => {
|
|
||||||
setProjectCache(
|
|
||||||
"value",
|
|
||||||
untrack(() => globalStore.project.map(sanitizeProject)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const setProjects = (next: Project[] | ((draft: Project[]) => Project[])) => {
|
const setProjects = (next: Project[] | ((draft: Project[]) => Project[])) => {
|
||||||
projectWritten = true
|
|
||||||
setGlobalStore("project", next)
|
setGlobalStore("project", next)
|
||||||
cacheProjects()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const setBootStore = ((...input: unknown[]) => {
|
const setBootStore = ((...input: unknown[]) => {
|
||||||
@ -117,16 +96,6 @@ function createGlobalSync() {
|
|||||||
return (setGlobalStore as (...args: unknown[]) => unknown)(...input)
|
return (setGlobalStore as (...args: unknown[]) => unknown)(...input)
|
||||||
}) as typeof setGlobalStore
|
}) as typeof setGlobalStore
|
||||||
|
|
||||||
if (projectInit instanceof Promise) {
|
|
||||||
void projectInit.then(() => {
|
|
||||||
if (!active) return
|
|
||||||
if (projectWritten) return
|
|
||||||
const cached = projectCache.value
|
|
||||||
if (cached.length === 0) return
|
|
||||||
setGlobalStore("project", cached)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const setSessionTodo = (sessionID: string, todos: Todo[] | undefined) => {
|
const setSessionTodo = (sessionID: string, todos: Todo[] | undefined) => {
|
||||||
if (!sessionID) return
|
if (!sessionID) return
|
||||||
if (!todos) {
|
if (!todos) {
|
||||||
|
|||||||
@ -156,13 +156,12 @@ export function createChildStoreManager(input: {
|
|||||||
|
|
||||||
const init = () =>
|
const init = () =>
|
||||||
createRoot((dispose) => {
|
createRoot((dispose) => {
|
||||||
const initialMeta = meta[0].value
|
|
||||||
const initialIcon = icon[0].value
|
const initialIcon = icon[0].value
|
||||||
|
|
||||||
const pathQuery = useQuery(() => loadPathQuery(directory))
|
const pathQuery = useQuery(() => loadPathQuery(directory))
|
||||||
const child = createStore<State>({
|
const child = createStore<State>({
|
||||||
project: "",
|
project: "",
|
||||||
projectMeta: initialMeta,
|
projectMeta: undefined,
|
||||||
icon: initialIcon,
|
icon: initialIcon,
|
||||||
provider_ready: false,
|
provider_ready: false,
|
||||||
provider: { all: [], connected: [], default: {} },
|
provider: { all: [], connected: [], default: {} },
|
||||||
@ -208,11 +207,6 @@ export function createChildStoreManager(input: {
|
|||||||
child[1]("vcs", (value) => value ?? cached)
|
child[1]("vcs", (value) => value ?? cached)
|
||||||
})
|
})
|
||||||
|
|
||||||
onPersistedInit(meta[2], () => {
|
|
||||||
if (child[0].projectMeta !== initialMeta) return
|
|
||||||
child[1]("projectMeta", meta[0].value)
|
|
||||||
})
|
|
||||||
|
|
||||||
onPersistedInit(icon[2], () => {
|
onPersistedInit(icon[2], () => {
|
||||||
if (child[0].icon !== initialIcon) return
|
if (child[0].icon !== initialIcon) return
|
||||||
child[1]("icon", icon[0].value)
|
child[1]("icon", icon[0].value)
|
||||||
|
|||||||
@ -391,37 +391,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
? globalSync.data.project.find((x) => x.id === projectID)
|
? globalSync.data.project.find((x) => x.id === projectID)
|
||||||
: globalSync.data.project.find((x) => x.worktree === project.worktree)
|
: globalSync.data.project.find((x) => x.worktree === project.worktree)
|
||||||
|
|
||||||
const local = childStore.projectMeta
|
return { ...metadata, ...project }
|
||||||
const localOverride =
|
|
||||||
local?.name !== undefined ||
|
|
||||||
local?.commands?.start !== undefined ||
|
|
||||||
local?.icon?.override !== undefined ||
|
|
||||||
local?.icon?.color !== undefined
|
|
||||||
|
|
||||||
const base = {
|
|
||||||
...metadata,
|
|
||||||
...project,
|
|
||||||
icon: {
|
|
||||||
url: metadata?.icon?.url,
|
|
||||||
override: metadata?.icon?.override ?? childStore.icon,
|
|
||||||
color: metadata?.icon?.color,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const isGlobal = projectID === "global" || (metadata?.id === undefined && localOverride)
|
|
||||||
if (!isGlobal) return base
|
|
||||||
|
|
||||||
return {
|
|
||||||
...base,
|
|
||||||
id: base.id ?? "global",
|
|
||||||
name: local?.name,
|
|
||||||
commands: local?.commands,
|
|
||||||
icon: {
|
|
||||||
url: base.icon?.url,
|
|
||||||
override: local?.icon?.override,
|
|
||||||
color: local?.icon?.color,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const roots = createMemo(() => {
|
const roots = createMemo(() => {
|
||||||
@ -516,7 +486,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const project of projects) {
|
for (const project of projects) {
|
||||||
if (project.icon?.color || project.icon.url) continue
|
if (project.icon?.color || project.icon?.override || project.icon?.url) continue
|
||||||
const worktree = project.worktree
|
const worktree = project.worktree
|
||||||
const existing = colors[worktree]
|
const existing = colors[worktree]
|
||||||
const color = existing ?? pickAvailableColor(used)
|
const color = existing ?? pickAvailableColor(used)
|
||||||
|
|||||||
@ -22,9 +22,7 @@ const OPENCODE_PROJECT_ID = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750"
|
|||||||
export function getProjectAvatarSource(id?: string, icon?: { color?: string; url?: string; override?: string }) {
|
export function getProjectAvatarSource(id?: string, icon?: { color?: string; url?: string; override?: string }) {
|
||||||
return id === OPENCODE_PROJECT_ID
|
return id === OPENCODE_PROJECT_ID
|
||||||
? "https://opencode.ai/favicon.svg"
|
? "https://opencode.ai/favicon.svg"
|
||||||
: icon?.color
|
: (icon?.override ?? (icon?.color ? undefined : icon?.url))
|
||||||
? undefined
|
|
||||||
: icon?.override || icon?.url
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ProjectIcon = (props: { project: LocalProject; class?: string; notify?: boolean }): JSX.Element => {
|
export const ProjectIcon = (props: { project: LocalProject; class?: string; notify?: boolean }): JSX.Element => {
|
||||||
|
|||||||
@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE `project` ADD `icon_url_override` text;
|
||||||
|
UPDATE `project` SET `icon_url_override` = `icon_url` WHERE `icon_url` IS NOT NULL;
|
||||||
File diff suppressed because it is too large
Load Diff
@ -8,6 +8,7 @@ export const ProjectTable = sqliteTable("project", {
|
|||||||
vcs: text(),
|
vcs: text(),
|
||||||
name: text(),
|
name: text(),
|
||||||
icon_url: text(),
|
icon_url: text(),
|
||||||
|
icon_url_override: text(),
|
||||||
icon_color: text(),
|
icon_color: text(),
|
||||||
...Timestamps,
|
...Timestamps,
|
||||||
time_initialized: integer(),
|
time_initialized: integer(),
|
||||||
|
|||||||
@ -60,7 +60,13 @@ type Row = typeof ProjectTable.$inferSelect
|
|||||||
|
|
||||||
export function fromRow(row: Row): Info {
|
export function fromRow(row: Row): Info {
|
||||||
const icon =
|
const icon =
|
||||||
row.icon_url || row.icon_color ? { url: row.icon_url ?? undefined, color: row.icon_color ?? undefined } : undefined
|
row.icon_url || row.icon_url_override || row.icon_color
|
||||||
|
? {
|
||||||
|
url: row.icon_url ?? undefined,
|
||||||
|
override: row.icon_url_override ?? undefined,
|
||||||
|
color: row.icon_color ?? undefined,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
worktree: row.worktree,
|
worktree: row.worktree,
|
||||||
@ -289,6 +295,7 @@ export const layer: Layer.Layer<
|
|||||||
vcs: result.vcs ?? null,
|
vcs: result.vcs ?? null,
|
||||||
name: result.name,
|
name: result.name,
|
||||||
icon_url: result.icon?.url,
|
icon_url: result.icon?.url,
|
||||||
|
icon_url_override: result.icon?.override,
|
||||||
icon_color: result.icon?.color,
|
icon_color: result.icon?.color,
|
||||||
time_created: result.time.created,
|
time_created: result.time.created,
|
||||||
time_updated: result.time.updated,
|
time_updated: result.time.updated,
|
||||||
@ -303,6 +310,7 @@ export const layer: Layer.Layer<
|
|||||||
vcs: result.vcs ?? null,
|
vcs: result.vcs ?? null,
|
||||||
name: result.name,
|
name: result.name,
|
||||||
icon_url: result.icon?.url,
|
icon_url: result.icon?.url,
|
||||||
|
icon_url_override: result.icon?.override,
|
||||||
icon_color: result.icon?.color,
|
icon_color: result.icon?.color,
|
||||||
time_updated: result.time.updated,
|
time_updated: result.time.updated,
|
||||||
time_initialized: result.time.initialized,
|
time_initialized: result.time.initialized,
|
||||||
@ -365,6 +373,7 @@ export const layer: Layer.Layer<
|
|||||||
.set({
|
.set({
|
||||||
name: input.name,
|
name: input.name,
|
||||||
icon_url: input.icon?.url,
|
icon_url: input.icon?.url,
|
||||||
|
icon_url_override: input.icon?.override,
|
||||||
icon_color: input.icon?.color,
|
icon_color: input.icon?.color,
|
||||||
commands: input.commands,
|
commands: input.commands,
|
||||||
time_updated: Date.now(),
|
time_updated: Date.now(),
|
||||||
|
|||||||
@ -168,6 +168,7 @@ export async function run(db: SQLiteBunDatabase<any, any> | NodeSQLiteDatabase<a
|
|||||||
vcs: data.vcs,
|
vcs: data.vcs,
|
||||||
name: data.name ?? undefined,
|
name: data.name ?? undefined,
|
||||||
icon_url: data.icon?.url,
|
icon_url: data.icon?.url,
|
||||||
|
icon_url_override: data.icon?.override,
|
||||||
icon_color: data.icon?.color,
|
icon_color: data.icon?.color,
|
||||||
time_created: data.time?.created ?? now,
|
time_created: data.time?.created ?? now,
|
||||||
time_updated: data.time?.updated ?? now,
|
time_updated: data.time?.updated ?? now,
|
||||||
|
|||||||
@ -278,6 +278,31 @@ describe("Project.discover", () => {
|
|||||||
expect(updated).toBeDefined()
|
expect(updated).toBeDefined()
|
||||||
expect(updated!.icon).toBeUndefined()
|
expect(updated!.icon).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("should not discover favicon when override is set", async () => {
|
||||||
|
await using tmp = await tmpdir({ git: true })
|
||||||
|
const { project } = await run((svc) => svc.fromDirectory(tmp.path))
|
||||||
|
|
||||||
|
await run((svc) =>
|
||||||
|
svc.update({
|
||||||
|
projectID: project.id,
|
||||||
|
icon: { override: "data:image/png;base64,override" },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const updatedProject = await run((svc) => svc.get(project.id))
|
||||||
|
if (!updatedProject) throw new Error("Project not found")
|
||||||
|
|
||||||
|
const pngData = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
|
||||||
|
await Bun.write(path.join(tmp.path, "favicon.png"), pngData)
|
||||||
|
|
||||||
|
await run((svc) => svc.discover(updatedProject))
|
||||||
|
|
||||||
|
const updated = Project.get(project.id)
|
||||||
|
expect(updated).toBeDefined()
|
||||||
|
expect(updated!.icon?.override).toBe("data:image/png;base64,override")
|
||||||
|
expect(updated!.icon?.url).toBeUndefined()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("Project.update", () => {
|
describe("Project.update", () => {
|
||||||
@ -332,6 +357,23 @@ describe("Project.update", () => {
|
|||||||
expect(fromDb?.icon?.color).toBe("#ff0000")
|
expect(fromDb?.icon?.color).toBe("#ff0000")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("should update icon override", async () => {
|
||||||
|
await using tmp = await tmpdir({ git: true })
|
||||||
|
const { project } = await run((svc) => svc.fromDirectory(tmp.path))
|
||||||
|
|
||||||
|
const updated = await run((svc) =>
|
||||||
|
svc.update({
|
||||||
|
projectID: project.id,
|
||||||
|
icon: { override: "data:image/png;base64,abc123" },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(updated.icon?.override).toBe("data:image/png;base64,abc123")
|
||||||
|
|
||||||
|
const fromDb = Project.get(project.id)
|
||||||
|
expect(fromDb?.icon?.override).toBe("data:image/png;base64,abc123")
|
||||||
|
})
|
||||||
|
|
||||||
test("should update commands", async () => {
|
test("should update commands", async () => {
|
||||||
await using tmp = await tmpdir({ git: true })
|
await using tmp = await tmpdir({ git: true })
|
||||||
const { project } = await run((svc) => svc.fromDirectory(tmp.path))
|
const { project } = await run((svc) => svc.fromDirectory(tmp.path))
|
||||||
@ -389,13 +431,14 @@ describe("Project.update", () => {
|
|||||||
svc.update({
|
svc.update({
|
||||||
projectID: project.id,
|
projectID: project.id,
|
||||||
name: "Multi Update",
|
name: "Multi Update",
|
||||||
icon: { url: "https://example.com/favicon.ico", color: "#00ff00" },
|
icon: { url: "https://example.com/favicon.ico", override: "data:image/png;base64,abc123", color: "#00ff00" },
|
||||||
commands: { start: "make start" },
|
commands: { start: "make start" },
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(updated.name).toBe("Multi Update")
|
expect(updated.name).toBe("Multi Update")
|
||||||
expect(updated.icon?.url).toBe("https://example.com/favicon.ico")
|
expect(updated.icon?.url).toBe("https://example.com/favicon.ico")
|
||||||
|
expect(updated.icon?.override).toBe("data:image/png;base64,abc123")
|
||||||
expect(updated.icon?.color).toBe("#00ff00")
|
expect(updated.icon?.color).toBe("#00ff00")
|
||||||
expect(updated.commands?.start).toBe("make start")
|
expect(updated.commands?.start).toBe("make start")
|
||||||
})
|
})
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user