feat: introduced a token-based theming system across the UI
* feat: added themes system * feat: smart sidebar auto-hide for files/diff tabs + lower files sidebar threshold * feat: added Checkbox component and update theming - Add reusable Checkbox component for toggles across UI - Replace several inputs with Checkbox in settings and commands panels - Add DiffIcon and apply surface/border theming to key UI areas * feat: Add convert-vscode-theme.cjs to convert VS Code themes to OpenChamber format * refactor: remove unused permission logic from ChatInput - Remove unused permission rules parsing logic from ChatInput - Memoize renderTheme in DiffWorkerProvider to avoid unnecessary recalculations - Remove forceOpaque helper in vscode theme adapter * fix: guard VSCode theme loading in MarkdownRenderer * feat: add custom user themes loading and reload - Load user themes from ~/.config/openchamber/themes at runtime - Expose /api/config/themes to fetch custom themes - Allow theme reloading from Settings → Theme → Reload themes in the UI
This commit is contained in:
committed by
GitHub
parent
5bb2c56bc0
commit
ddedc02687
@@ -26,6 +26,17 @@
|
||||
document.documentElement.style.setProperty('color-scheme', isDark ? 'dark' : 'light');
|
||||
// Store for use in inline styles
|
||||
window.__INITIAL_THEME_DARK__ = isDark;
|
||||
|
||||
// Splash colors persisted by the app theme system
|
||||
var splashBgLight = localStorage.getItem('splashBgLight');
|
||||
var splashFgLight = localStorage.getItem('splashFgLight');
|
||||
var splashBgDark = localStorage.getItem('splashBgDark');
|
||||
var splashFgDark = localStorage.getItem('splashFgDark');
|
||||
|
||||
if (splashBgLight) document.documentElement.style.setProperty('--splash-background-light', splashBgLight);
|
||||
if (splashFgLight) document.documentElement.style.setProperty('--splash-stroke-light', splashFgLight);
|
||||
if (splashBgDark) document.documentElement.style.setProperty('--splash-background-dark', splashBgDark);
|
||||
if (splashFgDark) document.documentElement.style.setProperty('--splash-stroke-dark', splashFgDark);
|
||||
})();
|
||||
</script>
|
||||
<style>
|
||||
@@ -54,18 +65,44 @@
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
background-color: transparent;
|
||||
}
|
||||
/* Theme-aware colors for loading screen */
|
||||
html.dark {
|
||||
--splash-stroke: white;
|
||||
:root {
|
||||
--splash-background-dark: #151313;
|
||||
--splash-stroke-dark: white;
|
||||
--splash-background-light: #F6F4EF;
|
||||
--splash-stroke-light: black;
|
||||
|
||||
--splash-background: var(--splash-background-dark);
|
||||
--splash-stroke: var(--splash-stroke-dark);
|
||||
|
||||
/* Fallback fills (overridden below when supported) */
|
||||
--splash-face-fill: rgba(255, 255, 255, 0.15);
|
||||
--splash-cell-fill: rgba(255, 255, 255, 0.35);
|
||||
--splash-logo-fill: white;
|
||||
--splash-logo-fill: var(--splash-stroke);
|
||||
}
|
||||
|
||||
html.light {
|
||||
--splash-stroke: black;
|
||||
--splash-background: var(--splash-background-light);
|
||||
--splash-stroke: var(--splash-stroke-light);
|
||||
--splash-face-fill: rgba(0, 0, 0, 0.15);
|
||||
--splash-cell-fill: rgba(0, 0, 0, 0.4);
|
||||
--splash-logo-fill: black;
|
||||
--splash-logo-fill: var(--splash-stroke);
|
||||
}
|
||||
|
||||
html.dark {
|
||||
--splash-background: var(--splash-background-dark);
|
||||
--splash-stroke: var(--splash-stroke-dark);
|
||||
--splash-logo-fill: var(--splash-stroke);
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--splash-background);
|
||||
}
|
||||
|
||||
@supports (color: color-mix(in srgb, white 50%, transparent)) {
|
||||
:root {
|
||||
--splash-face-fill: color-mix(in srgb, var(--splash-stroke) 15%, transparent);
|
||||
--splash-cell-fill: color-mix(in srgb, var(--splash-stroke) 35%, transparent);
|
||||
}
|
||||
}
|
||||
.loading {
|
||||
display: flex;
|
||||
|
||||
@@ -28,6 +28,7 @@ pub async fn load_settings(state: State<'_, DesktopRuntime>) -> Result<SettingsL
|
||||
.settings()
|
||||
.update_with(|mut settings| {
|
||||
migrate_legacy_project_settings(&mut settings);
|
||||
migrate_legacy_theme_settings(&mut settings);
|
||||
normalize_project_selection(&mut settings);
|
||||
(settings, ())
|
||||
})
|
||||
@@ -52,6 +53,7 @@ pub async fn save_settings(
|
||||
.settings()
|
||||
.update_with(|current| {
|
||||
let mut merged = merge_persisted_settings(¤t, &sanitized_changes);
|
||||
migrate_legacy_theme_settings(&mut merged);
|
||||
normalize_project_selection(&mut merged);
|
||||
(merged, ())
|
||||
})
|
||||
@@ -610,6 +612,70 @@ fn migrate_legacy_project_settings(settings: &mut Value) {
|
||||
}
|
||||
}
|
||||
|
||||
fn migrate_legacy_theme_settings(settings: &mut Value) {
|
||||
if !settings.is_object() {
|
||||
*settings = json!({});
|
||||
}
|
||||
|
||||
let obj = settings.as_object_mut().unwrap();
|
||||
|
||||
let theme_id = obj
|
||||
.get("themeId")
|
||||
.and_then(|value| value.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|value| value.to_string());
|
||||
|
||||
let theme_variant = obj
|
||||
.get("themeVariant")
|
||||
.and_then(|value| value.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|value| *value == "light" || *value == "dark")
|
||||
.map(|value| value.to_string());
|
||||
|
||||
let has_light = obj
|
||||
.get("lightThemeId")
|
||||
.and_then(|value| value.as_str())
|
||||
.is_some_and(|value| !value.trim().is_empty());
|
||||
let has_dark = obj
|
||||
.get("darkThemeId")
|
||||
.and_then(|value| value.as_str())
|
||||
.is_some_and(|value| !value.trim().is_empty());
|
||||
|
||||
if has_light && has_dark {
|
||||
return;
|
||||
}
|
||||
|
||||
let default_light = "flexoki-light".to_string();
|
||||
let default_dark = "flexoki-dark".to_string();
|
||||
|
||||
if !has_light {
|
||||
let next = if let (Some(id), Some(variant)) = (theme_id.as_ref(), theme_variant.as_ref()) {
|
||||
if variant == "light" {
|
||||
id.clone()
|
||||
} else {
|
||||
default_light.clone()
|
||||
}
|
||||
} else {
|
||||
default_light.clone()
|
||||
};
|
||||
obj.insert("lightThemeId".to_string(), json!(next));
|
||||
}
|
||||
|
||||
if !has_dark {
|
||||
let next = if let (Some(id), Some(variant)) = (theme_id.as_ref(), theme_variant.as_ref()) {
|
||||
if variant == "dark" {
|
||||
id.clone()
|
||||
} else {
|
||||
default_dark.clone()
|
||||
}
|
||||
} else {
|
||||
default_dark.clone()
|
||||
};
|
||||
obj.insert("darkThemeId".to_string(), json!(next));
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_project_selection(settings: &mut Value) {
|
||||
let Some(obj) = settings.as_object_mut() else {
|
||||
return;
|
||||
|
||||
@@ -92,6 +92,8 @@ const MODELS_DEV_API_URL: &str = "https://models.dev/api.json";
|
||||
const MODELS_METADATA_CACHE_TTL: Duration = Duration::from_secs(5 * 60);
|
||||
const MODELS_METADATA_REQUEST_TIMEOUT: Duration = Duration::from_secs(8);
|
||||
|
||||
const MAX_THEME_JSON_BYTES: u64 = 512 * 1024;
|
||||
|
||||
const CHECK_FOR_UPDATES_EVENT: &str = "openchamber:check-for-updates";
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -2059,6 +2061,14 @@ async fn handle_config_routes(
|
||||
method: Method,
|
||||
mut req: Request,
|
||||
) -> Result<Response, StatusCode> {
|
||||
if path == "/api/config/themes" && method == Method::GET {
|
||||
let themes = read_custom_themes_from_disk().await;
|
||||
return Ok(json_response(
|
||||
StatusCode::OK,
|
||||
serde_json::json!({ "themes": themes }),
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(name) = path.strip_prefix("/api/config/agents/") {
|
||||
let trimmed = name.trim();
|
||||
if trimmed.is_empty() {
|
||||
@@ -2564,6 +2574,7 @@ async fn proxy_to_opencode(
|
||||
let is_desktop_config_route = origin_path.starts_with("/api/config/agents/")
|
||||
|| origin_path.starts_with("/api/config/commands/")
|
||||
|| origin_path.starts_with("/api/config/skills")
|
||||
|| origin_path == "/api/config/themes"
|
||||
|| origin_path == "/api/config/reload"
|
||||
|| is_provider_auth_delete
|
||||
|| is_provider_source_get;
|
||||
@@ -2724,3 +2735,232 @@ impl SettingsStore {
|
||||
Ok(candidate)
|
||||
}
|
||||
}
|
||||
|
||||
fn openchamber_user_config_root() -> Option<PathBuf> {
|
||||
let home = dirs::home_dir()?;
|
||||
Some(home.join(".config").join("openchamber"))
|
||||
}
|
||||
|
||||
fn openchamber_themes_dir() -> Option<PathBuf> {
|
||||
openchamber_user_config_root().map(|root| root.join("themes"))
|
||||
}
|
||||
|
||||
fn value_non_empty_string(value: &Value) -> Option<String> {
|
||||
value
|
||||
.as_str()
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string())
|
||||
}
|
||||
|
||||
fn get_nested<'a>(value: &'a Value, path: &[&str]) -> Option<&'a Value> {
|
||||
let mut current = value;
|
||||
for key in path {
|
||||
current = current.get(*key)?;
|
||||
}
|
||||
Some(current)
|
||||
}
|
||||
|
||||
fn has_required_theme_fields(theme: &Value) -> bool {
|
||||
let required_paths: [&[&str]; 46] = [
|
||||
&["metadata", "id"],
|
||||
&["metadata", "name"],
|
||||
&["metadata", "variant"],
|
||||
&["colors", "primary", "base"],
|
||||
&["colors", "primary", "foreground"],
|
||||
&["colors", "surface", "background"],
|
||||
&["colors", "surface", "foreground"],
|
||||
&["colors", "surface", "muted"],
|
||||
&["colors", "surface", "mutedForeground"],
|
||||
&["colors", "surface", "elevated"],
|
||||
&["colors", "surface", "elevatedForeground"],
|
||||
&["colors", "surface", "subtle"],
|
||||
&["colors", "interactive", "border"],
|
||||
&["colors", "interactive", "selection"],
|
||||
&["colors", "interactive", "selectionForeground"],
|
||||
&["colors", "interactive", "focusRing"],
|
||||
&["colors", "interactive", "hover"],
|
||||
&["colors", "status", "error"],
|
||||
&["colors", "status", "errorForeground"],
|
||||
&["colors", "status", "errorBackground"],
|
||||
&["colors", "status", "errorBorder"],
|
||||
&["colors", "status", "warning"],
|
||||
&["colors", "status", "warningForeground"],
|
||||
&["colors", "status", "warningBackground"],
|
||||
&["colors", "status", "warningBorder"],
|
||||
&["colors", "status", "success"],
|
||||
&["colors", "status", "successForeground"],
|
||||
&["colors", "status", "successBackground"],
|
||||
&["colors", "status", "successBorder"],
|
||||
&["colors", "status", "info"],
|
||||
&["colors", "status", "infoForeground"],
|
||||
&["colors", "status", "infoBackground"],
|
||||
&["colors", "status", "infoBorder"],
|
||||
&["colors", "syntax", "base", "background"],
|
||||
&["colors", "syntax", "base", "foreground"],
|
||||
&["colors", "syntax", "base", "keyword"],
|
||||
&["colors", "syntax", "base", "string"],
|
||||
&["colors", "syntax", "base", "number"],
|
||||
&["colors", "syntax", "base", "function"],
|
||||
&["colors", "syntax", "base", "variable"],
|
||||
&["colors", "syntax", "base", "type"],
|
||||
&["colors", "syntax", "base", "comment"],
|
||||
&["colors", "syntax", "base", "operator"],
|
||||
&["colors", "syntax", "highlights", "diffAdded"],
|
||||
&["colors", "syntax", "highlights", "diffRemoved"],
|
||||
&["colors", "syntax", "highlights", "lineNumber"],
|
||||
];
|
||||
|
||||
for path in required_paths {
|
||||
let Some(value) = get_nested(theme, path) else {
|
||||
return false;
|
||||
};
|
||||
if value_non_empty_string(value).is_none() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
let variant = get_nested(theme, &["metadata", "variant"])
|
||||
.and_then(value_non_empty_string)
|
||||
.unwrap_or_default();
|
||||
if variant != "light" && variant != "dark" {
|
||||
return false;
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
fn normalize_theme_json(mut theme: Value) -> Option<Value> {
|
||||
if !theme.is_object() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if !has_required_theme_fields(&theme) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let id = get_nested(&theme, &["metadata", "id"]).and_then(value_non_empty_string)?;
|
||||
let name = get_nested(&theme, &["metadata", "name"]).and_then(value_non_empty_string)?;
|
||||
let variant = get_nested(&theme, &["metadata", "variant"]).and_then(value_non_empty_string)?;
|
||||
|
||||
// Ensure metadata exists and is an object.
|
||||
let metadata = theme
|
||||
.get_mut("metadata")
|
||||
.and_then(|v| v.as_object_mut())?;
|
||||
|
||||
metadata.insert("id".to_string(), Value::String(id.trim().to_string()));
|
||||
metadata.insert("name".to_string(), Value::String(name.trim().to_string()));
|
||||
metadata.insert("variant".to_string(), Value::String(variant));
|
||||
|
||||
if !metadata
|
||||
.get("description")
|
||||
.and_then(|v| v.as_str())
|
||||
.is_some()
|
||||
{
|
||||
metadata.insert("description".to_string(), Value::String("".to_string()));
|
||||
}
|
||||
|
||||
let version_ok = metadata
|
||||
.get("version")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.is_some();
|
||||
if !version_ok {
|
||||
metadata.insert("version".to_string(), Value::String("1.0.0".to_string()));
|
||||
}
|
||||
|
||||
if let Some(tags_value) = metadata.get_mut("tags") {
|
||||
if let Some(tags) = tags_value.as_array_mut() {
|
||||
tags.retain(|tag| tag.as_str().map(str::trim).filter(|s| !s.is_empty()).is_some());
|
||||
} else {
|
||||
*tags_value = Value::Array(vec![]);
|
||||
}
|
||||
} else {
|
||||
metadata.insert("tags".to_string(), Value::Array(vec![]));
|
||||
}
|
||||
|
||||
Some(theme)
|
||||
}
|
||||
|
||||
async fn read_custom_themes_from_disk() -> Vec<Value> {
|
||||
let Some(dir) = openchamber_themes_dir() else {
|
||||
return vec![];
|
||||
};
|
||||
|
||||
let mut results: Vec<Value> = vec![];
|
||||
let mut seen_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||
|
||||
let mut entries = match fs::read_dir(&dir).await {
|
||||
Ok(entries) => entries,
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return vec![],
|
||||
Err(err) => {
|
||||
warn!("[desktop:themes] Failed to list themes dir {:?}: {}", dir, err);
|
||||
return vec![];
|
||||
}
|
||||
};
|
||||
|
||||
while let Ok(Some(entry)) = entries.next_entry().await {
|
||||
let path = entry.path();
|
||||
let is_json = path
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.map(|ext| ext.eq_ignore_ascii_case("json"))
|
||||
.unwrap_or(false);
|
||||
if !is_json {
|
||||
continue;
|
||||
}
|
||||
|
||||
let metadata = match entry.metadata().await {
|
||||
Ok(v) => v,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if !metadata.is_file() {
|
||||
continue;
|
||||
}
|
||||
if metadata.len() > MAX_THEME_JSON_BYTES {
|
||||
warn!(
|
||||
"[desktop:themes] Skip {:?}: too large ({} bytes)",
|
||||
path,
|
||||
metadata.len()
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
let bytes = match fs::read(&path).await {
|
||||
Ok(bytes) => bytes,
|
||||
Err(err) => {
|
||||
warn!("[desktop:themes] Failed to read {:?}: {}", path, err);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let parsed: Value = match serde_json::from_slice(&bytes) {
|
||||
Ok(v) => v,
|
||||
Err(err) => {
|
||||
warn!("[desktop:themes] Invalid JSON {:?}: {}", path, err);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let normalized = match normalize_theme_json(parsed) {
|
||||
Some(v) => v,
|
||||
None => {
|
||||
warn!("[desktop:themes] Invalid theme JSON {:?}", path);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let id = get_nested(&normalized, &["metadata", "id"])
|
||||
.and_then(value_non_empty_string)
|
||||
.unwrap_or_default();
|
||||
if id.is_empty() || seen_ids.contains(&id) {
|
||||
continue;
|
||||
}
|
||||
seen_ids.insert(id);
|
||||
|
||||
results.push(normalized);
|
||||
}
|
||||
|
||||
results
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user