openpencil/crates/op-editor-core/src/codegen.rs

315 lines
10 KiB
Rust

//! UI-facing code-generation state, read by the Code panel painter and
//! shared by both hosts. Mirrors the TS `CodeGenProgress` shapes. Plain
//! data — keeps op-editor-core wasm-clean. Pipeline LOGIC lives in
//! op-codegen::ai; these are the types that crate returns (it depends on
//! op-editor-core, so the edge is acyclic).
use jian_core::text_input::prev_char_boundary;
/// Target framework for code generation. Wire tokens match TS `Framework`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Framework {
React,
Vue,
Svelte,
Html,
Flutter,
SwiftUi,
Compose,
ReactNative,
}
impl Framework {
pub const ALL: [Framework; 8] = [
Framework::React,
Framework::Vue,
Framework::Svelte,
Framework::Html,
Framework::Flutter,
Framework::SwiftUi,
Framework::Compose,
Framework::ReactNative,
];
pub fn as_wire(self) -> &'static str {
match self {
Framework::React => "react",
Framework::Vue => "vue",
Framework::Svelte => "svelte",
Framework::Html => "html",
Framework::Flutter => "flutter",
Framework::SwiftUi => "swiftui",
Framework::Compose => "compose",
Framework::ReactNative => "react-native",
}
}
pub fn from_wire(s: &str) -> Option<Framework> {
Framework::ALL.into_iter().find(|f| f.as_wire() == s)
}
/// Human display name (capitalized), for UI labels. TS parity.
pub fn display_name(self) -> &'static str {
match self {
Framework::React => "React",
Framework::Vue => "Vue",
Framework::Svelte => "Svelte",
Framework::Html => "HTML",
Framework::Flutter => "Flutter",
Framework::SwiftUi => "SwiftUI",
Framework::Compose => "Compose",
Framework::ReactNative => "React Native",
}
}
/// The framework-specific knowledge skill name (e.g. "codegen-react").
pub fn skill_name(self) -> &'static str {
match self {
Framework::React => "codegen-react",
Framework::Vue => "codegen-vue",
Framework::Svelte => "codegen-svelte",
Framework::Html => "codegen-html",
Framework::Flutter => "codegen-flutter",
Framework::SwiftUi => "codegen-swiftui",
Framework::Compose => "codegen-compose",
Framework::ReactNative => "codegen-react-native",
}
}
}
/// Per-chunk status. Parity with TS `ChunkStatus`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChunkStatus {
Pending,
Running,
Done,
Degraded,
Failed,
Skipped,
}
/// Top-level phase the panel renders. `Idle` = empty state.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CodegenPhase {
Idle,
Generating,
Complete,
Error,
}
/// Byte-offset text selection inside the generated code preview.
/// Offsets are clamped by the painter/hit-test against the currently
/// visible code text, so stale ranges after regeneration are harmless.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CodeSelection {
pub anchor: usize,
pub focus: usize,
}
impl CodeSelection {
pub fn ordered(self) -> (usize, usize) {
if self.anchor <= self.focus {
(self.anchor, self.focus)
} else {
(self.focus, self.anchor)
}
}
pub fn is_collapsed(self) -> bool {
self.anchor == self.focus
}
}
/// Code panel non-framework hover target. Framework chips keep their own
/// `framework_hover` because their state carries a selected framework value.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CodegenHover {
Generate,
Regenerate,
Cancel,
Copy,
Download,
ExportBundle,
ScrollFrameworksLeft,
ScrollFrameworksRight,
}
/// One chunk's progress row for the panel (id + display name + status).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ChunkProgress {
pub chunk_id: String,
pub name: String,
pub status: ChunkStatus,
}
/// Progress snapshot the pipeline produces and the panel paints. Parity
/// with the union TS `CodeGenProgress`, flattened into one struct so the
/// painter can render all three phase groups from a single value.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct CodeGenProgress {
/// planning: None = not started, Some(false) = running, Some(true) = done.
pub planning_done: Option<bool>,
pub chunks: Vec<ChunkProgress>,
/// assembly: None = not started, Some(false) = running, Some(true) = done.
pub assembly_done: Option<bool>,
}
/// Lightweight asset descriptor for the "includes assets" notice. The
/// raw bytes live in op-codegen's `AssetFile`; the host maps them here.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AssetMeta {
pub relative_path: String,
pub byte_len: usize,
}
/// The Code panel's full state. Mirror of `ChatState`'s role for chat.
/// `PartialEq` only (not `Eq`) — scroll offsets carry `f32` values.
#[derive(Debug, Clone, PartialEq)]
pub struct CodegenState {
pub framework: Framework,
/// Horizontal scroll offset (px, ≥ 0) of the framework tab strip, so the
/// single-row selector scrolls to reach off-screen frameworks (TS parity).
pub framework_scroll: jian_core::scroll::ScrollState,
/// The inactive framework chip the cursor is hovering, for a subtle
/// background highlight. `None` when the cursor is off the strip.
pub framework_hover: Option<Framework>,
/// Non-framework button / chevron the cursor is hovering.
pub action_hover: Option<CodegenHover>,
pub phase: CodegenPhase,
pub progress: CodeGenProgress,
pub code: String,
/// Vertical scroll offset (px, >= 0) inside the generated-code preview.
pub code_scroll: jian_core::scroll::ScrollState,
/// Text selection inside the generated-code preview.
pub code_selection: Option<CodeSelection>,
pub degraded: bool,
pub assets: Vec<AssetMeta>,
/// Node ids the last generation ran against — to detect selection drift.
pub selection_snapshot: Vec<String>,
pub error: Option<String>,
/// Frame at which "Copied" was shown, to time the transient label.
pub copied_at: Option<u64>,
/// Set by the Code panel's Generate action; drained by the host codegen
/// session (P3). Mirror of `chat.pending_send`.
pub pending_generate: bool,
/// Set by Regenerate; drained by the host codegen session (P3).
pub pending_regenerate: bool,
/// Set by the Code panel's Download action; drained by the desktop
/// codegen-export drain (Task 5) which pops a save dialog + writes the
/// generated code (single file, or a .zip when there are image assets).
pub pending_download: bool,
/// Set by Export AI Bundle; drained by the desktop codegen-export drain
/// which writes a structure-bundle .zip.
pub pending_export_bundle: bool,
/// Set by the Code panel's Cancel action; drained by the desktop
/// codegen-session cancel drain, which raises the in-flight worker's
/// shared abort flag so the run actually stops (TS parity:
/// `abortRef.current?.abort()`), not just the painted phase.
pub pending_cancel: bool,
}
impl Default for CodegenState {
fn default() -> Self {
Self {
framework: Framework::React,
framework_scroll: Default::default(),
framework_hover: None,
action_hover: None,
phase: CodegenPhase::Idle,
progress: CodeGenProgress::default(),
code: String::new(),
code_scroll: Default::default(),
code_selection: None,
degraded: false,
assets: Vec::new(),
selection_snapshot: Vec::new(),
error: None,
copied_at: None,
pending_generate: false,
pending_regenerate: false,
pending_download: false,
pending_export_bundle: false,
pending_cancel: false,
}
}
}
impl CodegenState {
pub fn selected_code_text(&self) -> Option<&str> {
let selection = self.code_selection?;
if selection.is_collapsed() || self.code.is_empty() {
return None;
}
let (start, end) = selection.ordered();
let start = prev_char_boundary(&self.code, start.min(self.code.len()));
let end = prev_char_boundary(&self.code, end.min(self.code.len()));
if start >= end {
return None;
}
Some(&self.code[start..end])
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn framework_round_trips_its_wire_token() {
for fw in Framework::ALL {
assert_eq!(Framework::from_wire(fw.as_wire()), Some(fw));
}
assert_eq!(Framework::from_wire("react"), Some(Framework::React));
assert_eq!(
Framework::from_wire("react-native"),
Some(Framework::ReactNative)
);
assert_eq!(Framework::from_wire("nope"), None);
// display_name is the capitalized UI label (TS parity).
assert_eq!(Framework::React.display_name(), "React");
assert_eq!(Framework::ReactNative.display_name(), "React Native");
assert_eq!(Framework::SwiftUi.display_name(), "SwiftUI");
}
#[test]
fn codegen_state_defaults_to_idle_react() {
let s = CodegenState::default();
assert_eq!(s.framework, Framework::React);
assert_eq!(s.phase, CodegenPhase::Idle);
assert!(s.code.is_empty());
assert_eq!(s.code_scroll.offset, 0.0);
assert!(!s.degraded);
assert!(s.error.is_none());
assert!(!s.pending_generate);
assert!(!s.pending_regenerate);
assert!(!s.pending_download);
assert!(!s.pending_export_bundle);
assert!(!s.pending_cancel);
}
#[test]
fn codegen_scroll_fields_use_scroll_state() {
let mut s = CodegenState::default();
s.framework_scroll.offset = 16.0;
s.code_scroll.offset = 32.0;
assert_eq!(s.framework_scroll.offset, 16.0);
assert_eq!(s.code_scroll.offset, 32.0);
}
#[test]
fn selected_code_text_returns_non_collapsed_range() {
let s = CodegenState {
code: "import React\nconst n = 1".into(),
code_selection: Some(CodeSelection {
anchor: 0,
focus: 6,
}),
..CodegenState::default()
};
assert_eq!(s.selected_code_text(), Some("import"));
}
}