Skip to content

Commit f35c727

Browse files
minsoo-webclaude
andcommitted
feat: add Ghostty target support and improve code quality
Add Ghostty as a third theme migration target with config-based activation, diff preview, and backup. Refactor Target dispatch into impl methods for extensibility. Replace --no-activate with --activate opt-in flag. Cherry-pick quality improvements: fix strip_jsonc escaped backslash bug, replace unwrap_or(0) with proper error handling, extract magic numbers to constants, add tests for superset/warp/store/reader. Code review fixes: remove dead as_rgb(), fix ghostty_config_dir panic, harden filename sanitization, extract shared test fixture, remove unused serde derives. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 468dac2 commit f35c727

16 files changed

Lines changed: 892 additions & 115 deletions

File tree

Cargo.lock

Lines changed: 11 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
[package]
22
name = "chromaport"
3-
version = "0.1.0"
3+
version = "0.2.0"
44
edition = "2021"
55
description = "Migrate VS Code / Cursor themes to Superset, Warp, and more"
66
license = "MIT"
77
repository = "https://github.com/hamsurang/chromaport"
88
homepage = "https://github.com/hamsurang/chromaport"
9-
keywords = ["theme", "vscode", "cursor", "superset", "warp"]
9+
keywords = ["theme", "vscode", "cursor", "superset", "warp", "ghostty"]
1010

1111
[[bin]]
1212
name = "chromaport"
@@ -22,6 +22,8 @@ rayon = "1"
2222
serde = { version = "1", features = ["derive"] }
2323
serde_yaml_ng = "0.10"
2424
serde_json = "1"
25+
similar = { version = "2", features = ["inline"] }
26+
console = "0.15"
2527
tempfile = "3"
2628

2729
[dev-dependencies]

README.md

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<p align="center">
2-
<img src="assets/chromaport_og.png" alt="Chromaport" width="600" />
2+
<img src="assets/chromaport.png" alt="Chromaport" width="600" />
33
</p>
44

55
# chromaport
@@ -70,17 +70,17 @@ chromaport --editor vscode --target superset --yes
7070

7171
## Supported editors
7272

73-
| Editor | Path |
74-
|--------|------|
73+
| Editor | Path |
74+
| ------- | ----------------------- |
7575
| VS Code | `~/.vscode/extensions/` |
76-
| Cursor | `~/.cursor/extensions/` |
76+
| Cursor | `~/.cursor/extensions/` |
7777

7878
## Supported targets
7979

80-
| Target | How it works |
81-
|--------|-------------|
82-
| Superset | Writes to `~/.superset/app-state.json` (quit Superset first) |
83-
| Warp | Writes to `~/.warp/themes/*.yaml` (auto-detected while running) |
80+
| Target | How it works |
81+
| -------- | --------------------------------------------------------------- |
82+
| Superset | Writes to `~/.superset/app-state.json` (quit Superset first) |
83+
| Warp | Writes to `~/.warp/themes/*.yaml` (auto-detected while running) |
8484

8585
## How it works
8686

assets/chromaport.png

15.4 KB
Loading

src/cli.rs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ use clap::{Parser, ValueEnum};
33
#[derive(Parser)]
44
#[command(
55
version,
6-
about = "Migrate VS Code / Cursor themes to Superset, Warp, and more",
6+
about = "Migrate VS Code / Cursor themes to Superset, Warp, Ghostty, and more",
77
long_about = None
88
)]
99
pub struct Cli {
@@ -20,8 +20,12 @@ pub struct Cli {
2020
#[arg(short = 'y', long)]
2121
pub yes: bool,
2222

23-
/// Do not change the active theme after import
23+
/// Apply the theme to the target app's config
2424
#[arg(long)]
25+
pub activate: bool,
26+
27+
/// Deprecated: themes are no longer activated by default. Use --activate instead.
28+
#[arg(long, hide = true)]
2529
pub no_activate: bool,
2630
}
2731

@@ -35,4 +39,5 @@ pub enum Editor {
3539
pub enum Target {
3640
Superset,
3741
Warp,
42+
Ghostty,
3843
}

src/converter.rs

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,8 @@ fn get_color(
8282
}
8383
}
8484
}
85-
HexColor::parse(fallback).unwrap_or_else(|_| HexColor::parse("#000000").unwrap())
85+
HexColor::parse(fallback)
86+
.unwrap_or_else(|_| HexColor::parse("#000000").expect("valid hex literal"))
8687
}
8788

8889
/// Chart color scopes to search for (in priority order)
@@ -132,16 +133,12 @@ fn extract_chart_colors(
132133
}
133134
}
134135
}
135-
result.push(HexColor::parse(fallback).unwrap());
136+
result.push(HexColor::parse(fallback).expect("CHART_SCOPES fallbacks are valid hex"));
136137
}
137138

138-
[
139-
result.remove(0),
140-
result.remove(0),
141-
result.remove(0),
142-
result.remove(0),
143-
result.remove(0),
144-
]
139+
result
140+
.try_into()
141+
.expect("CHART_SCOPES has exactly 5 entries")
145142
}
146143

147144
/// Convert a VS Code theme (ThemeEntry + parsed JSON) into the intermediate representation.
@@ -208,15 +205,23 @@ pub fn convert(entry: &ThemeEntry, theme_json: &Value) -> Result<ThemeIR> {
208205
.or_else(|| colors.get("editor.selectionBackground"))
209206
.and_then(|v| HexColor::parse(v).ok());
210207

211-
let ansi = |normal_key: &str, bright_key: &str, dk: &str, lk: &str| {
208+
let ansi = |normal_key: &str, bright_key: &str, normal_default: &str, bright_default: &str| {
212209
let fallback = if matches!(theme_type, ThemeType::Dark) {
213-
dk
210+
normal_default
214211
} else {
215-
lk
212+
bright_default
216213
};
217214
(
218-
get_color(colors, &[normal_key], d.get(dk).unwrap_or(&fallback)),
219-
get_color(colors, &[bright_key], d.get(lk).unwrap_or(&fallback)),
215+
get_color(
216+
colors,
217+
&[normal_key],
218+
d.get(normal_default).unwrap_or(&fallback),
219+
),
220+
get_color(
221+
colors,
222+
&[bright_key],
223+
d.get(bright_default).unwrap_or(&fallback),
224+
),
220225
)
221226
};
222227

src/interactive.rs

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ use anyhow::Result;
55
use inquire::{InquireError, MultiSelect, Select};
66
use std::io::IsTerminal;
77

8+
const THEME_LIST_PAGE_SIZE: usize = 12;
9+
810
pub fn is_tty() -> bool {
911
std::io::stdin().is_terminal()
1012
}
@@ -23,7 +25,10 @@ pub fn select_editor(available: &[(Editor, String)]) -> Result<usize> {
2325
.prompt()
2426
.map_err(handle_inquire_error)?;
2527

26-
Ok(options.iter().position(|o| o == &selected).unwrap_or(0))
28+
options
29+
.iter()
30+
.position(|o| o == &selected)
31+
.ok_or_else(|| anyhow::anyhow!("selected item not found in options"))
2732
}
2833

2934
/// Let user select one or more themes to migrate.
@@ -56,7 +61,7 @@ pub fn select_themes(themes: &[ThemeEntry], active_id: Option<&str>) -> Result<V
5661
.collect();
5762

5863
let selected = MultiSelect::new("Select themes to migrate:", options.clone())
59-
.with_page_size(12)
64+
.with_page_size(THEME_LIST_PAGE_SIZE)
6065
.prompt()
6166
.map_err(handle_inquire_error)?;
6267

@@ -83,19 +88,22 @@ pub fn select_target(available: &[Target]) -> Result<Target> {
8388

8489
if available.len() == 1 {
8590
let t = available[0].clone();
86-
println!("Target: {} (auto-detected)", target_name(&t));
91+
println!("Target: {} (auto-detected)", t.display_name());
8792
return Ok(t);
8893
}
8994

9095
let options: Vec<String> = available
9196
.iter()
92-
.map(|t| target_name(t).to_string())
97+
.map(|t| t.display_name().to_string())
9398
.collect();
9499
let selected = Select::new("Select target app:", options.clone())
95100
.prompt()
96101
.map_err(handle_inquire_error)?;
97102

98-
let idx = options.iter().position(|o| o == &selected).unwrap_or(0);
103+
let idx = options
104+
.iter()
105+
.position(|o| o == &selected)
106+
.ok_or_else(|| anyhow::anyhow!("selected item not found in options"))?;
99107
Ok(available[idx].clone())
100108
}
101109

@@ -116,16 +124,20 @@ pub fn select_active(irs: &[ThemeIR]) -> Result<Option<String>> {
116124
if selected == keep {
117125
Ok(None)
118126
} else {
119-
let idx = options.iter().position(|o| o == &selected).unwrap_or(0);
127+
let idx = options
128+
.iter()
129+
.position(|o| o == &selected)
130+
.ok_or_else(|| anyhow::anyhow!("selected item not found in options"))?;
120131
Ok(irs.get(idx).map(|ir| ir.id.clone()))
121132
}
122133
}
123134

124-
fn target_name(t: &Target) -> &'static str {
125-
match t {
126-
Target::Superset => "Superset",
127-
Target::Warp => "Warp",
128-
}
135+
pub fn confirm_activate() -> Result<bool> {
136+
let answer = inquire::Confirm::new("Apply this change?")
137+
.with_default(false)
138+
.prompt()
139+
.map_err(handle_inquire_error)?;
140+
Ok(answer)
129141
}
130142

131143
fn handle_inquire_error(e: InquireError) -> anyhow::Error {

src/ir.rs

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,22 @@ pub struct AnsiPalette {
149149
pub white: HexColor,
150150
}
151151

152+
impl AnsiPalette {
153+
/// Returns palette colors indexed from `offset` (0 for normal, 8 for bright).
154+
pub fn as_indexed(&self, offset: u8) -> [(u8, &HexColor); 8] {
155+
[
156+
(offset, &self.black),
157+
(offset + 1, &self.red),
158+
(offset + 2, &self.green),
159+
(offset + 3, &self.yellow),
160+
(offset + 4, &self.blue),
161+
(offset + 5, &self.magenta),
162+
(offset + 6, &self.cyan),
163+
(offset + 7, &self.white),
164+
]
165+
}
166+
}
167+
152168
#[derive(Debug, Clone)]
153169
pub struct AnsiColors {
154170
pub normal: AnsiPalette,
@@ -181,3 +197,53 @@ pub struct ThemeIR {
181197
// Terminal ANSI
182198
pub terminal: AnsiColors,
183199
}
200+
201+
#[cfg(test)]
202+
pub(crate) mod test_fixtures {
203+
use super::*;
204+
205+
pub fn make_test_ir() -> ThemeIR {
206+
let hex = |s: &str| HexColor::parse(s).unwrap();
207+
let palette = || AnsiPalette {
208+
black: hex("#000000"),
209+
red: hex("#FF0000"),
210+
green: hex("#00FF00"),
211+
yellow: hex("#FFFF00"),
212+
blue: hex("#0000FF"),
213+
magenta: hex("#FF00FF"),
214+
cyan: hex("#00FFFF"),
215+
white: hex("#FFFFFF"),
216+
};
217+
ThemeIR {
218+
id: "test-theme".to_string(),
219+
name: "Test Theme".to_string(),
220+
theme_type: ThemeType::Dark,
221+
background: hex("#1E1E1E"),
222+
foreground: hex("#D4D4D4"),
223+
accent: hex("#0078D4"),
224+
cursor: hex("#D4D4D4"),
225+
selection_bg: hex("#264F78"),
226+
border: hex("#3E3E3E"),
227+
sidebar_bg: hex("#252526"),
228+
sidebar_fg: hex("#CCCCCC"),
229+
input_bg: hex("#3C3C3C"),
230+
muted_fg: hex("#858585"),
231+
chart_colors: [
232+
hex("#E06C75"),
233+
hex("#98C379"),
234+
hex("#61AFEF"),
235+
hex("#C678DD"),
236+
hex("#56B6C2"),
237+
],
238+
terminal: AnsiColors {
239+
normal: palette(),
240+
bright: palette(),
241+
background: hex("#1E1E1E"),
242+
foreground: hex("#D4D4D4"),
243+
cursor: hex("#D4D4D4"),
244+
cursor_accent: None,
245+
selection_bg: Some(hex("#264F78")),
246+
},
247+
}
248+
}
249+
}

0 commit comments

Comments
 (0)