feat(settings): move config/state to owner buckets
Add generic /api/storage config/state endpoints with merge-patch, migrate legacy YAML/JSON layout, and update UI/server to read and write owner-scoped settings. Replace config SSE events and drop /api/config routes.
This commit is contained in:
@@ -58,6 +58,7 @@ const App: Component = () => {
|
||||
const { t } = useI18n()
|
||||
const {
|
||||
preferences,
|
||||
serverSettings,
|
||||
recordWorkspaceLaunch,
|
||||
toggleShowThinkingBlocks,
|
||||
toggleShowTimelineTools,
|
||||
@@ -177,7 +178,7 @@ const App: Component = () => {
|
||||
return
|
||||
}
|
||||
setIsSelectingFolder(true)
|
||||
const selectedBinary = binaryPath || preferences().lastUsedBinary || "opencode"
|
||||
const selectedBinary = binaryPath || serverSettings().opencodeBinary || "opencode"
|
||||
try {
|
||||
recordWorkspaceLaunch(folderPath, selectedBinary)
|
||||
clearLaunchError()
|
||||
|
||||
@@ -10,12 +10,12 @@ interface EnvironmentVariablesEditorProps {
|
||||
const EnvironmentVariablesEditor: Component<EnvironmentVariablesEditorProps> = (props) => {
|
||||
const { t } = useI18n()
|
||||
const {
|
||||
preferences,
|
||||
serverSettings,
|
||||
addEnvironmentVariable,
|
||||
removeEnvironmentVariable,
|
||||
updateEnvironmentVariables,
|
||||
} = useConfig()
|
||||
const [envVars, setEnvVars] = createSignal<Record<string, string>>(preferences().environmentVariables || {})
|
||||
const [envVars, setEnvVars] = createSignal<Record<string, string>>(serverSettings().environmentVariables || {})
|
||||
const [newKey, setNewKey] = createSignal("")
|
||||
const [newValue, setNewValue] = createSignal("")
|
||||
|
||||
|
||||
@@ -26,11 +26,11 @@ interface FolderSelectionViewProps {
|
||||
}
|
||||
|
||||
const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
const { recentFolders, removeRecentFolder, preferences, updatePreferences } = useConfig()
|
||||
const { recentFolders, removeRecentFolder, preferences, updatePreferences, serverSettings, updateLastUsedBinary } = useConfig()
|
||||
const { t, locale } = useI18n()
|
||||
const [selectedIndex, setSelectedIndex] = createSignal(0)
|
||||
const [focusMode, setFocusMode] = createSignal<"recent" | "new" | null>("recent")
|
||||
const [selectedBinary, setSelectedBinary] = createSignal(preferences().lastUsedBinary || "opencode")
|
||||
const [selectedBinary, setSelectedBinary] = createSignal(serverSettings().opencodeBinary || "opencode")
|
||||
const [isFolderBrowserOpen, setIsFolderBrowserOpen] = createSignal(false)
|
||||
const nativeDialogsAvailable = supportsNativeDialogs()
|
||||
let recentListRef: HTMLDivElement | undefined
|
||||
@@ -53,7 +53,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
|
||||
// Update selected binary when preferences change
|
||||
createEffect(() => {
|
||||
const lastUsed = preferences().lastUsedBinary
|
||||
const lastUsed = serverSettings().opencodeBinary
|
||||
if (!lastUsed) return
|
||||
setSelectedBinary((current) => (current === lastUsed ? current : lastUsed))
|
||||
})
|
||||
|
||||
@@ -5,7 +5,7 @@ import { ChevronDown, Star } from "lucide-solid"
|
||||
import type { Model } from "../types/session"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
import { getLogger } from "../lib/logger"
|
||||
import { preferences, toggleFavoriteModelPreference } from "../stores/preferences"
|
||||
import { uiState, toggleFavoriteModelPreference } from "../stores/preferences"
|
||||
const log = getLogger("session")
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ export default function ModelSelector(props: ModelSelectorProps) {
|
||||
|
||||
const favoriteKeySet = createMemo(() => {
|
||||
const result = new Set<string>()
|
||||
for (const item of preferences().modelFavorites ?? []) {
|
||||
for (const item of uiState().models.favorites ?? []) {
|
||||
if (item.providerId && item.modelId) {
|
||||
result.add(`${item.providerId}/${item.modelId}`)
|
||||
}
|
||||
|
||||
@@ -29,8 +29,8 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
|
||||
opencodeBinaries,
|
||||
addOpenCodeBinary,
|
||||
removeOpenCodeBinary,
|
||||
preferences,
|
||||
updatePreferences,
|
||||
serverSettings,
|
||||
updateLastUsedBinary,
|
||||
} = useConfig()
|
||||
const [customPath, setCustomPath] = createSignal("")
|
||||
const [validating, setValidating] = createSignal(false)
|
||||
@@ -42,7 +42,7 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
|
||||
|
||||
const binaries = () => opencodeBinaries()
|
||||
|
||||
const lastUsedBinary = () => preferences().lastUsedBinary
|
||||
const lastUsedBinary = () => serverSettings().opencodeBinary
|
||||
|
||||
const customBinaries = createMemo(() => binaries().filter((binary) => binary.path !== "opencode"))
|
||||
|
||||
@@ -158,7 +158,7 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
|
||||
if (validation.valid) {
|
||||
addOpenCodeBinary(path, validation.version)
|
||||
props.onBinaryChange(path)
|
||||
updatePreferences({ lastUsedBinary: path })
|
||||
updateLastUsedBinary(path)
|
||||
setCustomPath("")
|
||||
setValidationError(null)
|
||||
} else {
|
||||
@@ -183,7 +183,7 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
|
||||
if (props.disabled) return
|
||||
if (path === props.selectedBinary) return
|
||||
props.onBinaryChange(path)
|
||||
updatePreferences({ lastUsedBinary: path })
|
||||
updateLastUsedBinary(path)
|
||||
}
|
||||
|
||||
function handleRemoveBinary(path: string, event: Event) {
|
||||
@@ -193,7 +193,7 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
|
||||
|
||||
if (props.selectedBinary === path) {
|
||||
props.onBinaryChange("opencode")
|
||||
updatePreferences({ lastUsedBinary: "opencode" })
|
||||
updateLastUsedBinary("opencode")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { ExternalLink, Link2, Loader2, RefreshCw, Shield, Wifi } from "lucide-so
|
||||
import type { NetworkAddress, ServerMeta } from "../../../server/src/api-types"
|
||||
import { serverApi } from "../lib/api-client"
|
||||
import { restartCli } from "../lib/native/cli"
|
||||
import { preferences, setListeningMode } from "../stores/preferences"
|
||||
import { serverSettings, setListeningMode } from "../stores/preferences"
|
||||
import { showConfirmDialog } from "../stores/alerts"
|
||||
import { getLogger } from "../lib/logger"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
@@ -33,7 +33,7 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
||||
const [savingPassword, setSavingPassword] = createSignal(false)
|
||||
|
||||
const addresses = createMemo<NetworkAddress[]>(() => meta()?.addresses ?? [])
|
||||
const currentMode = createMemo(() => meta()?.listeningMode ?? preferences().listeningMode)
|
||||
const currentMode = createMemo(() => meta()?.listeningMode ?? serverSettings().listeningMode)
|
||||
const allowExternalConnections = createMemo(() => currentMode() === "all")
|
||||
const displayAddresses = createMemo(() => {
|
||||
const list = addresses()
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import type {
|
||||
AppConfig,
|
||||
BackgroundProcess,
|
||||
BackgroundProcessListResponse,
|
||||
BackgroundProcessOutputResponse,
|
||||
BinaryCreateRequest,
|
||||
BinaryListResponse,
|
||||
BinaryUpdateRequest,
|
||||
BinaryValidationResult,
|
||||
FileSystemEntry,
|
||||
FileSystemCreateFolderResponse,
|
||||
@@ -214,37 +210,27 @@ export const serverApi = {
|
||||
)
|
||||
},
|
||||
|
||||
fetchConfig(): Promise<AppConfig> {
|
||||
return request<AppConfig>("/api/config/app")
|
||||
fetchConfigOwner<T extends Record<string, any> = Record<string, any>>(owner: string): Promise<T> {
|
||||
return request<T>(`/api/storage/config/${encodeURIComponent(owner)}`)
|
||||
},
|
||||
updateConfig(payload: AppConfig): Promise<AppConfig> {
|
||||
return request<AppConfig>("/api/config/app", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
},
|
||||
listBinaries(): Promise<BinaryListResponse> {
|
||||
return request<BinaryListResponse>("/api/config/binaries")
|
||||
},
|
||||
createBinary(payload: BinaryCreateRequest) {
|
||||
return request<{ binary: BinaryListResponse["binaries"][number] }>("/api/config/binaries", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
},
|
||||
|
||||
updateBinary(id: string, updates: BinaryUpdateRequest) {
|
||||
return request<{ binary: BinaryListResponse["binaries"][number] }>(`/api/config/binaries/${encodeURIComponent(id)}`, {
|
||||
patchConfigOwner<T extends Record<string, any> = Record<string, any>>(owner: string, patch: unknown): Promise<T> {
|
||||
return request<T>(`/api/storage/config/${encodeURIComponent(owner)}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(updates),
|
||||
body: JSON.stringify(patch ?? {}),
|
||||
})
|
||||
},
|
||||
fetchStateOwner<T extends Record<string, any> = Record<string, any>>(owner: string): Promise<T> {
|
||||
return request<T>(`/api/storage/state/${encodeURIComponent(owner)}`)
|
||||
},
|
||||
patchStateOwner<T extends Record<string, any> = Record<string, any>>(owner: string, patch: unknown): Promise<T> {
|
||||
return request<T>(`/api/storage/state/${encodeURIComponent(owner)}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(patch ?? {}),
|
||||
})
|
||||
},
|
||||
|
||||
deleteBinary(id: string): Promise<void> {
|
||||
return request(`/api/config/binaries/${encodeURIComponent(id)}`, { method: "DELETE" })
|
||||
},
|
||||
validateBinary(path: string): Promise<BinaryValidationResult> {
|
||||
return request<BinaryValidationResult>("/api/config/binaries/validate", {
|
||||
return request<BinaryValidationResult>("/api/storage/binaries/validate", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ path }),
|
||||
})
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { AppConfig, InstanceData } from "../../../server/src/api-types"
|
||||
import type { InstanceData, WorkspaceEventPayload } from "../../../server/src/api-types"
|
||||
import { serverApi } from "./api-client"
|
||||
import { serverEvents } from "./server-events"
|
||||
import { getLogger } from "./logger"
|
||||
|
||||
const log = getLogger("actions")
|
||||
|
||||
export type ConfigData = AppConfig
|
||||
export type OwnerBucket = Record<string, any>
|
||||
|
||||
const DEFAULT_INSTANCE_DATA: InstanceData = {
|
||||
messageHistory: [],
|
||||
@@ -30,17 +30,25 @@ function isDeepEqual(a: unknown, b: unknown): boolean {
|
||||
}
|
||||
|
||||
export class ServerStorage {
|
||||
private configChangeListeners: Set<(config: ConfigData) => void> = new Set()
|
||||
private configCache: ConfigData | null = null
|
||||
private loadPromise: Promise<ConfigData> | null = null
|
||||
private configOwnerCache = new Map<string, OwnerBucket>()
|
||||
private stateOwnerCache = new Map<string, OwnerBucket>()
|
||||
private configOwnerLoadPromises = new Map<string, Promise<OwnerBucket>>()
|
||||
private stateOwnerLoadPromises = new Map<string, Promise<OwnerBucket>>()
|
||||
private configOwnerListeners = new Map<string, Set<(value: OwnerBucket) => void>>()
|
||||
private stateOwnerListeners = new Map<string, Set<(value: OwnerBucket) => void>>()
|
||||
private instanceDataCache = new Map<string, InstanceData>()
|
||||
private instanceDataListeners = new Map<string, Set<(data: InstanceData) => void>>()
|
||||
private instanceLoadPromises = new Map<string, Promise<InstanceData>>()
|
||||
|
||||
constructor() {
|
||||
serverEvents.on("config.appChanged", (event) => {
|
||||
if (event.type !== "config.appChanged") return
|
||||
this.setConfigCache(event.config)
|
||||
serverEvents.on("storage.configChanged", (event: WorkspaceEventPayload) => {
|
||||
if (event.type !== "storage.configChanged") return
|
||||
this.setOwnerCache("config", event.owner, event.value)
|
||||
})
|
||||
|
||||
serverEvents.on("storage.stateChanged", (event: WorkspaceEventPayload) => {
|
||||
if (event.type !== "storage.stateChanged") return
|
||||
this.setOwnerCache("state", event.owner, event.value)
|
||||
})
|
||||
|
||||
serverEvents.on("instance.dataChanged", (event) => {
|
||||
@@ -49,30 +57,56 @@ export class ServerStorage {
|
||||
})
|
||||
}
|
||||
|
||||
async loadConfig(): Promise<ConfigData> {
|
||||
if (this.configCache) {
|
||||
return this.configCache
|
||||
}
|
||||
async loadConfigOwner(owner: string): Promise<OwnerBucket> {
|
||||
const cached = this.configOwnerCache.get(owner)
|
||||
if (cached) return cached
|
||||
|
||||
if (!this.loadPromise) {
|
||||
this.loadPromise = serverApi
|
||||
.fetchConfig()
|
||||
.then((config) => {
|
||||
this.setConfigCache(config)
|
||||
return config
|
||||
if (!this.configOwnerLoadPromises.has(owner)) {
|
||||
const promise = serverApi
|
||||
.fetchConfigOwner<OwnerBucket>(owner)
|
||||
.then((value) => {
|
||||
this.setOwnerCache("config", owner, value)
|
||||
return value
|
||||
})
|
||||
.finally(() => {
|
||||
this.loadPromise = null
|
||||
this.configOwnerLoadPromises.delete(owner)
|
||||
})
|
||||
this.configOwnerLoadPromises.set(owner, promise)
|
||||
}
|
||||
|
||||
return this.loadPromise
|
||||
return this.configOwnerLoadPromises.get(owner)!
|
||||
}
|
||||
|
||||
async updateConfig(next: ConfigData): Promise<ConfigData> {
|
||||
const nextConfig = await serverApi.updateConfig(next)
|
||||
this.setConfigCache(nextConfig)
|
||||
return nextConfig
|
||||
async patchConfigOwner(owner: string, patch: unknown): Promise<OwnerBucket> {
|
||||
const updated = await serverApi.patchConfigOwner<OwnerBucket>(owner, patch)
|
||||
this.setOwnerCache("config", owner, updated)
|
||||
return updated
|
||||
}
|
||||
|
||||
async loadStateOwner(owner: string): Promise<OwnerBucket> {
|
||||
const cached = this.stateOwnerCache.get(owner)
|
||||
if (cached) return cached
|
||||
|
||||
if (!this.stateOwnerLoadPromises.has(owner)) {
|
||||
const promise = serverApi
|
||||
.fetchStateOwner<OwnerBucket>(owner)
|
||||
.then((value) => {
|
||||
this.setOwnerCache("state", owner, value)
|
||||
return value
|
||||
})
|
||||
.finally(() => {
|
||||
this.stateOwnerLoadPromises.delete(owner)
|
||||
})
|
||||
this.stateOwnerLoadPromises.set(owner, promise)
|
||||
}
|
||||
|
||||
return this.stateOwnerLoadPromises.get(owner)!
|
||||
}
|
||||
|
||||
async patchStateOwner(owner: string, patch: unknown): Promise<OwnerBucket> {
|
||||
const updated = await serverApi.patchStateOwner<OwnerBucket>(owner, patch)
|
||||
this.setOwnerCache("state", owner, updated)
|
||||
return updated
|
||||
}
|
||||
|
||||
async loadInstanceData(instanceId: string): Promise<InstanceData> {
|
||||
@@ -110,12 +144,40 @@ export class ServerStorage {
|
||||
this.setInstanceDataCache(instanceId, DEFAULT_INSTANCE_DATA)
|
||||
}
|
||||
|
||||
onConfigChanged(listener: (config: ConfigData) => void): () => void {
|
||||
this.configChangeListeners.add(listener)
|
||||
if (this.configCache) {
|
||||
listener(this.configCache)
|
||||
onConfigOwnerChanged(owner: string, listener: (value: OwnerBucket) => void): () => void {
|
||||
if (!this.configOwnerListeners.has(owner)) {
|
||||
this.configOwnerListeners.set(owner, new Set())
|
||||
}
|
||||
const bucket = this.configOwnerListeners.get(owner)!
|
||||
bucket.add(listener)
|
||||
const cached = this.configOwnerCache.get(owner)
|
||||
if (cached) {
|
||||
listener(cached)
|
||||
}
|
||||
return () => {
|
||||
bucket.delete(listener)
|
||||
if (bucket.size === 0) {
|
||||
this.configOwnerListeners.delete(owner)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onStateOwnerChanged(owner: string, listener: (value: OwnerBucket) => void): () => void {
|
||||
if (!this.stateOwnerListeners.has(owner)) {
|
||||
this.stateOwnerListeners.set(owner, new Set())
|
||||
}
|
||||
const bucket = this.stateOwnerListeners.get(owner)!
|
||||
bucket.add(listener)
|
||||
const cached = this.stateOwnerCache.get(owner)
|
||||
if (cached) {
|
||||
listener(cached)
|
||||
}
|
||||
return () => {
|
||||
bucket.delete(listener)
|
||||
if (bucket.size === 0) {
|
||||
this.stateOwnerListeners.delete(owner)
|
||||
}
|
||||
}
|
||||
return () => this.configChangeListeners.delete(listener)
|
||||
}
|
||||
|
||||
onInstanceDataChanged(instanceId: string, listener: (data: InstanceData) => void): () => void {
|
||||
@@ -136,18 +198,30 @@ export class ServerStorage {
|
||||
}
|
||||
}
|
||||
|
||||
private setConfigCache(config: ConfigData) {
|
||||
if (this.configCache && isDeepEqual(this.configCache, config)) {
|
||||
this.configCache = config
|
||||
private setOwnerCache(kind: "config" | "state", owner: string, value: OwnerBucket) {
|
||||
if (owner === "*") {
|
||||
// Full-doc updates are not tracked owner-by-owner; invalidate caches.
|
||||
if (kind === "config") {
|
||||
this.configOwnerCache.clear()
|
||||
} else {
|
||||
this.stateOwnerCache.clear()
|
||||
}
|
||||
return
|
||||
}
|
||||
this.configCache = config
|
||||
this.notifyConfigChanged(config)
|
||||
}
|
||||
|
||||
private notifyConfigChanged(config: ConfigData) {
|
||||
for (const listener of this.configChangeListeners) {
|
||||
listener(config)
|
||||
const cache = kind === "config" ? this.configOwnerCache : this.stateOwnerCache
|
||||
const listeners = kind === "config" ? this.configOwnerListeners : this.stateOwnerListeners
|
||||
|
||||
const previous = cache.get(owner)
|
||||
if (previous && isDeepEqual(previous, value)) {
|
||||
cache.set(owner, value)
|
||||
return
|
||||
}
|
||||
cache.set(owner, value)
|
||||
const bucket = listeners.get(owner)
|
||||
if (!bucket) return
|
||||
for (const listener of bucket) {
|
||||
listener(value)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -30,8 +30,8 @@ async function bootstrap() {
|
||||
document.documentElement.removeAttribute("data-theme")
|
||||
|
||||
try {
|
||||
const config = await storage.loadConfig()
|
||||
const theme = config?.theme ?? "system"
|
||||
const uiConfig = await storage.loadConfigOwner("ui")
|
||||
const theme = (uiConfig as any)?.theme ?? "system"
|
||||
|
||||
if (theme === "system") {
|
||||
document.documentElement.removeAttribute("data-theme")
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
} from "./sessions"
|
||||
import { ensureWorktreesLoaded, ensureWorktreeMapLoaded, getOrCreateWorktreeClient, getWorktreeSlugForSession } from "./worktrees"
|
||||
import { fetchCommands, clearCommands } from "./commands"
|
||||
import { preferences } from "./preferences"
|
||||
import { serverSettings } from "./preferences"
|
||||
import { setSessionPendingPermission, setSessionPendingQuestion } from "./session-state"
|
||||
import { setHasInstances } from "./ui"
|
||||
import { messageStoreBus } from "./message-v2/bus"
|
||||
@@ -91,7 +91,7 @@ function workspaceDescriptorToInstance(descriptor: WorkspaceDescriptor): Instanc
|
||||
binaryPath: descriptor.binaryId ?? descriptor.binaryLabel ?? existing?.binaryPath,
|
||||
binaryLabel: descriptor.binaryLabel,
|
||||
binaryVersion: descriptor.binaryVersion ?? existing?.binaryVersion,
|
||||
environmentVariables: existing?.environmentVariables ?? preferences().environmentVariables ?? {},
|
||||
environmentVariables: existing?.environmentVariables ?? serverSettings().environmentVariables ?? {},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
import { agents, providers } from "./session-state"
|
||||
import { preferences, getAgentModelPreference } from "./preferences"
|
||||
import { uiState, getAgentModelPreference } from "./preferences"
|
||||
|
||||
const DEFAULT_MODEL_OUTPUT_LIMIT = 32_000
|
||||
|
||||
@@ -17,7 +17,7 @@ function isModelValid(
|
||||
function getRecentModelPreferenceForInstance(
|
||||
instanceId: string,
|
||||
): { providerId: string; modelId: string } | undefined {
|
||||
const recents = preferences().modelRecents ?? []
|
||||
const recents = uiState().models.recents ?? []
|
||||
for (const item of recents) {
|
||||
if (isModelValid(instanceId, item)) {
|
||||
return item
|
||||
|
||||
Reference in New Issue
Block a user