Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
a25bb60
fix(terminal): clamp resize to minimum 1 col/row to prevent panic
jonasnobile Feb 16, 2026
8a89297
feat(core): add Backspace and Delete special keys
jonasnobile Feb 16, 2026
ff76d05
feat(core): add folders, project ordering, and folder colors to API
jonasnobile Feb 16, 2026
d9d8ced
feat(core): add terminal size propagation in layout tree
jonasnobile Feb 16, 2026
5ab1c31
feat(desktop): include folders, project order, and terminal sizes in …
jonasnobile Feb 16, 2026
6c726cd
chore(mobile/ios): configure iOS project with CocoaPods and signing
jonasnobile Feb 16, 2026
d9af454
feat(mobile): add design system with OkenaColors and OkenaTypography
jonasnobile Feb 16, 2026
b95d0a3
feat(mobile): add terminal scroll, resize, and display offset FFI
jonasnobile Feb 16, 2026
2587a2b
feat(mobile): add folders, project ordering, and terminal management FFI
jonasnobile Feb 16, 2026
eafb24a
feat(mobile): redesign app with iOS-native dark theme and terminal im…
jonasnobile Feb 16, 2026
96fdb0d
chore(mobile): regenerate flutter_rust_bridge bindings
jonasnobile Feb 16, 2026
550789b
chore(mobile): remove google_fonts dependency and add devtools config
jonasnobile Feb 16, 2026
2bf67fc
feat(mobile): add LayoutNode sealed class model and update tests
jonasnobile Mar 29, 2026
6a17963
refactor(mobile): extract send_action_with_response in ConnectionManager
jonasnobile Mar 29, 2026
ae14fbe
feat(mobile): add fullscreen, git, services, and terminal management FFI
jonasnobile Mar 29, 2026
9f8269f
chore(mobile): regenerate flutter_rust_bridge bindings
jonasnobile Mar 29, 2026
ffdb0c2
feat(mobile): add fullscreen, services, git status, and layout manage…
jonasnobile Mar 29, 2026
8e5f223
feat(mobile): add terminal selection and scroll info APIs
jonasnobile Mar 29, 2026
8a19f03
feat(mobile): add layout management, project reorder, and git file co…
jonasnobile Mar 29, 2026
3fb738f
chore(mobile): regenerate flutter_rust_bridge bindings
jonasnobile Mar 29, 2026
47a281d
feat(mobile): add resizable splits, tab management, and minimized ter…
jonasnobile Mar 29, 2026
68c5505
feat(mobile): add project drawer enhancements (add project, reorder, …
jonasnobile Mar 29, 2026
9addd69
feat(mobile): add git diff viewer and file contents viewer
jonasnobile Mar 29, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions crates/okena-core/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,10 @@ pub enum ApiLayoutNode {
terminal_id: Option<String>,
minimized: bool,
detached: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
cols: Option<u16>,
#[serde(default, skip_serializing_if = "Option::is_none")]
rows: Option<u16>,
},
Split {
direction: SplitDirection,
Expand Down Expand Up @@ -334,13 +338,17 @@ mod tests {
terminal_id: Some("t1".into()),
minimized: false,
detached: false,
cols: None,
rows: None,
},
ApiLayoutNode::Tabs {
active_tab: 0,
children: vec![ApiLayoutNode::Terminal {
terminal_id: Some("t2".into()),
minimized: true,
detached: true,
cols: None,
rows: None,
}],
},
],
Expand Down Expand Up @@ -552,6 +560,8 @@ mod tests {
terminal_id: Some("t1".into()),
minimized: false,
detached: false,
cols: None,
rows: None,
},
ApiLayoutNode::Tabs {
active_tab: 0,
Expand All @@ -560,16 +570,22 @@ mod tests {
terminal_id: Some("t2".into()),
minimized: false,
detached: false,
cols: None,
rows: None,
},
ApiLayoutNode::Terminal {
terminal_id: None,
minimized: false,
detached: false,
cols: None,
rows: None,
},
ApiLayoutNode::Terminal {
terminal_id: Some("t3".into()),
minimized: false,
detached: true,
cols: None,
rows: None,
},
],
},
Expand Down
17 changes: 15 additions & 2 deletions crates/okena-core/src/client/connection.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use crate::api::StateResponse;
use crate::client::config::RemoteConnectionConfig;
use crate::client::id::make_prefixed_id;
use crate::client::state::{collect_all_terminal_ids, collect_state_terminal_ids, diff_states};
use crate::client::state::{collect_all_terminal_ids, collect_state_terminal_ids, collect_terminal_sizes, diff_states};
use crate::client::types::{
ConnectionEvent, ConnectionStatus, SessionError, WsClientMessage, TOKEN_REFRESH_AGE_SECS,
};
Expand All @@ -17,12 +17,15 @@ use tokio_tungstenite::tungstenite;
pub trait ConnectionHandler: Send + Sync + 'static {
/// Terminal discovered — create platform terminal object.
/// `ws_sender` is for constructing a transport that sends WS commands.
/// `cols`/`rows` are the server's current terminal dimensions (0 if unknown).
fn create_terminal(
&self,
connection_id: &str,
terminal_id: &str,
prefixed_id: &str,
ws_sender: async_channel::Sender<WsClientMessage>,
cols: u16,
rows: u16,
);
/// Binary PTY output arrived — route to the terminal's emulator.
fn on_terminal_output(&self, prefixed_id: &str, data: &[u8]);
Expand Down Expand Up @@ -606,9 +609,11 @@ impl<H: ConnectionHandler> RemoteClient<H> {
handler.remove_terminals_except(&config.id, &current_ids);

let terminal_ids = collect_state_terminal_ids(&state);
let size_map = collect_terminal_sizes(&state);
for tid in &terminal_ids {
let prefixed = make_prefixed_id(&config.id, tid);
handler.create_terminal(&config.id, tid, &prefixed, ws_tx.clone());
let (cols, rows) = size_map.get(tid).copied().unwrap_or((0, 0));
handler.create_terminal(&config.id, tid, &prefixed, ws_tx.clone(), cols, rows);
}

// Notify state received
Expand Down Expand Up @@ -824,16 +829,24 @@ impl<H: ConnectionHandler> RemoteClient<H> {
{
let diff =
diff_states(&cached_state, &new_state);
let new_size_map =
collect_terminal_sizes(&new_state);

// Add new terminals via handler
for tid in &diff.added_terminals {
let prefixed =
make_prefixed_id(&config_id, tid);
let (cols, rows) = new_size_map
.get(tid)
.copied()
.unwrap_or((0, 0));
handler_clone.create_terminal(
&config_id,
tid,
&prefixed,
ws_tx_clone.clone(),
cols,
rows,
);
}

Expand Down
2 changes: 1 addition & 1 deletion crates/okena-core/src/client/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,5 @@ pub mod types;
pub use config::RemoteConnectionConfig;
pub use connection::{ConnectionHandler, RemoteClient};
pub use id::{is_remote_terminal, make_prefixed_id, strip_prefix};
pub use state::{collect_all_terminal_ids, collect_state_terminal_ids, diff_states, StateDiff};
pub use state::{collect_all_terminal_ids, collect_state_terminal_ids, collect_terminal_sizes, diff_states, StateDiff};
pub use types::{ConnectionEvent, ConnectionStatus, WsClientMessage, TOKEN_REFRESH_AGE_SECS};
77 changes: 77 additions & 0 deletions crates/okena-core/src/client/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -112,10 +112,48 @@ fn collect_layout_ids_vec(node: &ApiLayoutNode, ids: &mut Vec<String>) {
}
}

/// Collect terminal sizes from all projects in a StateResponse.
///
/// Returns a map of terminal_id → (cols, rows) for terminals that have
/// size information in the layout tree.
pub fn collect_terminal_sizes(state: &StateResponse) -> std::collections::HashMap<String, (u16, u16)> {
let mut sizes = std::collections::HashMap::new();
for project in &state.projects {
if let Some(ref layout) = project.layout {
collect_layout_terminal_sizes(layout, &mut sizes);
}
}
sizes
}

fn collect_layout_terminal_sizes(
node: &ApiLayoutNode,
sizes: &mut std::collections::HashMap<String, (u16, u16)>,
) {
match node {
ApiLayoutNode::Terminal {
terminal_id,
cols,
rows,
..
} => {
if let (Some(id), Some(c), Some(r)) = (terminal_id, cols, rows) {
sizes.insert(id.clone(), (*c, *r));
}
}
ApiLayoutNode::Split { children, .. } | ApiLayoutNode::Tabs { children, .. } => {
for child in children {
collect_layout_terminal_sizes(child, sizes);
}
}
}
}

#[cfg(test)]
mod tests {
use super::*;
use crate::api::{ApiLayoutNode, ApiProject, StateResponse};
use crate::theme::FolderColor;
use crate::types::SplitDirection;

fn make_state(projects: Vec<ApiProject>) -> StateResponse {
Expand All @@ -137,6 +175,8 @@ mod tests {
terminal_id: Some(terminal_ids[0].to_string()),
minimized: false,
detached: false,
cols: None,
rows: None,
})
} else {
Some(ApiLayoutNode::Split {
Expand All @@ -148,6 +188,8 @@ mod tests {
terminal_id: Some(tid.to_string()),
minimized: false,
detached: false,
cols: None,
rows: None,
})
.collect(),
})
Expand Down Expand Up @@ -200,4 +242,39 @@ mod tests {
assert!(diff.removed_terminals.is_empty());
assert!(diff.changed_projects.is_empty());
}

#[test]
fn collect_terminal_sizes_extracts_from_layout() {
let state = make_state(vec![ApiProject {
id: "p1".into(),
name: "p1".into(),
path: "/tmp".into(),
is_visible: true,
layout: Some(ApiLayoutNode::Split {
direction: SplitDirection::Horizontal,
sizes: vec![50.0, 50.0],
children: vec![
ApiLayoutNode::Terminal {
terminal_id: Some("t1".into()),
minimized: false,
detached: false,
cols: Some(120),
rows: Some(40),
},
ApiLayoutNode::Terminal {
terminal_id: Some("t2".into()),
minimized: false,
detached: false,
cols: None,
rows: None,
},
],
}),
terminal_names: Default::default(),
folder_color: FolderColor::default(),
}]);
let sizes = collect_terminal_sizes(&state);
assert_eq!(sizes.get("t1"), Some(&(120, 40)));
assert_eq!(sizes.get("t2"), None);
}
}
16 changes: 8 additions & 8 deletions crates/okena-core/src/keys.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ use serde::{Deserialize, Serialize};
pub enum SpecialKey {
Enter,
Escape,
Backspace,
Delete,
CtrlC,
CtrlD,
CtrlZ,
Expand All @@ -17,8 +19,6 @@ pub enum SpecialKey {
End,
PageUp,
PageDown,
Backspace,
Delete,
}

impl SpecialKey {
Expand All @@ -27,6 +27,8 @@ impl SpecialKey {
match self {
SpecialKey::Enter => b"\r",
SpecialKey::Escape => b"\x1b",
SpecialKey::Backspace => b"\x7f",
SpecialKey::Delete => b"\x1b[3~",
SpecialKey::CtrlC => b"\x03",
SpecialKey::CtrlD => b"\x04",
SpecialKey::CtrlZ => b"\x1a",
Expand All @@ -39,8 +41,6 @@ impl SpecialKey {
SpecialKey::End => b"\x1b[F",
SpecialKey::PageUp => b"\x1b[5~",
SpecialKey::PageDown => b"\x1b[6~",
SpecialKey::Backspace => b"\x7f",
SpecialKey::Delete => b"\x1b[3~",
}
}
}
Expand All @@ -54,6 +54,8 @@ mod tests {
let keys = vec![
SpecialKey::Enter,
SpecialKey::Escape,
SpecialKey::Backspace,
SpecialKey::Delete,
SpecialKey::CtrlC,
SpecialKey::CtrlD,
SpecialKey::CtrlZ,
Expand All @@ -66,8 +68,6 @@ mod tests {
SpecialKey::End,
SpecialKey::PageUp,
SpecialKey::PageDown,
SpecialKey::Backspace,
SpecialKey::Delete,
];
for key in keys {
let json = serde_json::to_string(&key).unwrap();
Expand All @@ -80,6 +80,8 @@ mod tests {
fn special_key_to_bytes() {
assert_eq!(SpecialKey::Enter.to_bytes(), b"\r");
assert_eq!(SpecialKey::Escape.to_bytes(), b"\x1b");
assert_eq!(SpecialKey::Backspace.to_bytes(), b"\x7f");
assert_eq!(SpecialKey::Delete.to_bytes(), b"\x1b[3~");
assert_eq!(SpecialKey::CtrlC.to_bytes(), b"\x03");
assert_eq!(SpecialKey::CtrlD.to_bytes(), b"\x04");
assert_eq!(SpecialKey::CtrlZ.to_bytes(), b"\x1a");
Expand All @@ -92,7 +94,5 @@ mod tests {
assert_eq!(SpecialKey::End.to_bytes(), b"\x1b[F");
assert_eq!(SpecialKey::PageUp.to_bytes(), b"\x1b[5~");
assert_eq!(SpecialKey::PageDown.to_bytes(), b"\x1b[6~");
assert_eq!(SpecialKey::Backspace.to_bytes(), b"\x7f");
assert_eq!(SpecialKey::Delete.to_bytes(), b"\x1b[3~");
}
}
9 changes: 8 additions & 1 deletion crates/okena-remote-client/src/connection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ impl ConnectionHandler for DesktopConnectionHandler {
_terminal_id: &str,
prefixed_id: &str,
ws_sender: async_channel::Sender<WsClientMessage>,
cols: u16,
rows: u16,
) {
let mut terminals = self.terminals.lock();
// Skip if terminal already exists — on reconnect the server re-sends
Expand All @@ -43,9 +45,14 @@ impl ConnectionHandler for DesktopConnectionHandler {
ws_tx: ws_sender,
connection_id: connection_id.to_string(),
});
let size = if cols > 0 && rows > 0 {
TerminalSize { cols, rows, ..TerminalSize::default() }
} else {
TerminalSize::default()
};
let terminal = Arc::new(Terminal::new(
prefixed_id.to_string(),
TerminalSize::default(),
size,
transport,
String::new(),
));
Expand Down
7 changes: 6 additions & 1 deletion crates/okena-terminal/src/terminal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -428,6 +428,11 @@ impl Terminal {
pub fn resize(&self, new_size: TerminalSize) {
let debounce_ms = self.transport.resize_debounce_ms();

// Clamp to at least 1 col/row - alacritty_terminal panics on zero dimensions
let cols = new_size.cols.max(1);
let rows = new_size.rows.max(1);
let new_size = TerminalSize { cols, rows, ..new_size };

// Always update local size immediately (optimistic UI)
{
let mut rs = self.resize_state.lock();
Expand All @@ -437,7 +442,7 @@ impl Terminal {

// Resize terminal grid immediately (independent mutex)
let mut term = self.term.lock();
let term_size = TermSize::new(new_size.cols as usize, new_size.rows as usize);
let term_size = TermSize::new(cols as usize, rows as usize);
term.resize(term_size);
drop(term);

Expand Down
Loading
Loading