use std::{
future::poll_fn,
path::PathBuf,
time::Duration,
};
use async_io::Timer;
use freya::{
prelude::{
AccessibilityId,
Focus,
spawn_forever,
},
radio::{
RadioChannel,
RadioStation,
},
};
use freya_terminal::prelude::*;
use portable_pty::CommandBuilder;
use crate::config::Config;
pub static CONFIG: std::sync::OnceLock<Config> = std::sync::OnceLock::new();
fn find_common_parent(paths: &[PathBuf]) -> PathBuf {
if paths.is_empty() {
return PathBuf::from(".");
}
if paths.len() == 1 {
return paths[0].parent().unwrap_or(&paths[0]).to_path_buf();
}
let first_components: Vec<_> = paths[0].components().collect();
for i in (0..first_components.len()).rev() {
let candidate = first_components[..=i]
.iter()
.collect::<std::path::PathBuf>();
if paths.iter().all(|p| p.starts_with(&candidate)) {
return candidate;
}
}
paths[0].parent().unwrap_or(&paths[0]).to_path_buf()
}
#[derive(Clone)]
pub struct TerminalState {
pub id: usize,
pub terminal_id: TerminalId,
pub handle: Option<TerminalHandle>,
pub focus_id: AccessibilityId,
pub is_thinking: bool,
pub command: String,
}
impl PartialEq for TerminalState {
fn eq(&self, other: &Self) -> bool {
self.id == other.id
&& self.terminal_id == other.terminal_id
&& self.is_thinking == other.is_thinking
}
}
#[derive(Clone, PartialEq)]
pub struct Instance {
pub id: usize,
pub project_id: usize,
pub name: String,
pub path: PathBuf,
pub terminals: Vec<TerminalState>,
pub focused_terminal_id: Option<AccessibilityId>,
}
#[derive(Clone, PartialEq)]
pub struct Project {
pub id: usize,
pub name: String,
pub path: PathBuf,
}
#[derive(Clone, PartialEq)]
pub struct AppState {
pub projects: Vec<Project>,
pub instances: Vec<Instance>,
pub font_size: f32,
next_project_id: usize,
next_instance_id: usize,
}
impl TerminalState {
pub fn new(id: usize, cmd: CommandBuilder, command: String) -> Self {
let terminal_id = TerminalId::new();
let handle = TerminalHandle::new(terminal_id, cmd, None).ok();
Self {
id,
handle,
terminal_id,
focus_id: Focus::new_id(),
is_thinking: false,
command,
}
}
}
impl Instance {
pub fn new(id: usize, project_id: usize, name: String, path: PathBuf) -> Self {
Self {
id,
project_id,
name,
path,
terminals: Vec::new(),
focused_terminal_id: None,
}
}
pub fn create_default_terminals(&mut self) {
let config = CONFIG.get().cloned().unwrap_or_default();
for i in 0..4 {
let command = if i == 0 {
config.corner_command.clone()
} else {
config.shell.clone()
};
let mut cmd = CommandBuilder::new(&command);
cmd.env("TERM", "xterm-256color");
cmd.env("COLORTERM", "truecolor");
cmd.env("LANG", "en_GB.UTF-8");
cmd.cwd(&self.path);
let terminal = TerminalState::new(i, cmd, command);
self.terminals.push(terminal);
}
if let Some(first) = self.terminals.first() {
self.focused_terminal_id = Some(first.focus_id);
}
}
fn terminal_index_by_focus_id(&self, focus_id: AccessibilityId) -> Option<usize> {
self.terminals.iter().position(|t| t.focus_id == focus_id)
}
}
impl Project {
pub fn new(id: usize, name: String, path: PathBuf) -> Self {
Self { id, name, path }
}
pub fn list_subdirectories(&self) -> Vec<PathBuf> {
let Ok(entries) = std::fs::read_dir(&self.path) else {
return Vec::new();
};
let mut dirs: Vec<(PathBuf, std::time::SystemTime)> = entries
.filter_map(|entry| {
let entry = entry.ok()?;
entry.file_type().ok()?.is_dir().then_some(())?;
let path = entry.path();
let fs_modified = entry.metadata().ok()?.modified().ok()?;
let modified = self.last_activity_time(&path).unwrap_or(fs_modified);
Some((path, modified))
})
.collect();
dirs.sort_by(|a, b| b.1.cmp(&a.1));
dirs.into_iter().map(|(path, _)| path).collect()
}
fn last_activity_time(&self, dir: &std::path::Path) -> Option<std::time::SystemTime> {
let repo = git2::Repository::open(dir).ok()?;
let commit_time = repo.head().ok()?.peel_to_commit().ok()?.time();
let commit_time = std::time::UNIX_EPOCH
.checked_add(std::time::Duration::from_secs(commit_time.seconds() as u64))?;
let index_time = std::fs::metadata(repo.path().join("index"))
.and_then(|m| m.modified())
.ok();
let dir_time = std::fs::metadata(dir).and_then(|m| m.modified()).ok();
[Some(commit_time), index_time, dir_time]
.into_iter()
.flatten()
.max()
}
}
impl AppState {
pub fn new() -> Self {
let config = CONFIG.get().cloned().unwrap_or_default();
let mut state = Self {
projects: Vec::new(),
instances: Vec::new(),
font_size: config.font_size,
next_project_id: 0,
next_instance_id: 0,
};
if let Some(cli) = super::cli::CLI_ARGS.get() {
for project_path in &cli.project {
let path = PathBuf::from(project_path);
if let Ok(canonical) = std::fs::canonicalize(&path) {
if canonical.is_dir() {
let name = canonical
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "Project".to_string());
state.add_project(name, canonical);
}
}
}
if !cli.instance.is_empty() {
let instance_paths: Vec<PathBuf> = cli
.instance
.iter()
.map(PathBuf::from)
.filter_map(|p| {
let canonical = std::fs::canonicalize(&p).ok()?;
if canonical.is_dir() {
Some(canonical)
} else {
None
}
})
.collect();
if !instance_paths.is_empty() {
let common_parent = find_common_parent(&instance_paths);
let project_id = state
.projects
.iter()
.find(|p| {
common_parent.starts_with(&p.path) || p.path.starts_with(&common_parent)
})
.map(|p| p.id)
.unwrap_or_else(|| {
let name = common_parent
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "Project".to_string());
state.add_project(name, common_parent)
});
for path in instance_paths {
let name = path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "Instance".to_string());
state.add_instance(project_id, name, path);
}
}
}
}
state
}
pub fn add_project(&mut self, name: String, path: PathBuf) -> usize {
let id = self.next_project_id;
self.next_project_id += 1;
self.projects.push(Project::new(id, name, path));
id
}
pub fn add_instance(&mut self, project_id: usize, name: String, path: PathBuf) -> usize {
let id = self.next_instance_id;
self.next_instance_id += 1;
let mut instance = Instance::new(id, project_id, name, path);
instance.create_default_terminals();
self.instances.push(instance);
id
}
pub fn remove_project(&mut self, project_id: usize) {
self.instances.retain(|i| i.project_id != project_id);
self.projects.retain(|p| p.id != project_id);
}
pub fn remove_instance(&mut self, instance_id: usize) {
self.instances.retain(|i| i.id != instance_id);
}
pub fn focus_terminal_right(&mut self, instance_id: usize) {
if let Some(instance) = self.instances.iter_mut().find(|i| i.id == instance_id) {
let Some(focus_id) = instance.focused_terminal_id else {
return;
};
let Some(idx) = instance.terminal_index_by_focus_id(focus_id) else {
return;
};
let row = idx / 2;
let col = idx % 2;
if col < 1 {
let new_idx = row * 2 + col + 1;
if let Some(t) = instance.terminals.get(new_idx) {
instance.focused_terminal_id = Some(t.focus_id);
Focus::new_for_id(t.focus_id).request_focus();
}
}
}
}
pub fn focus_terminal_left(&mut self, instance_id: usize) {
if let Some(instance) = self.instances.iter_mut().find(|i| i.id == instance_id) {
let Some(focus_id) = instance.focused_terminal_id else {
return;
};
let Some(idx) = instance.terminal_index_by_focus_id(focus_id) else {
return;
};
let row = idx / 2;
let col = idx % 2;
if col > 0 {
let new_idx = row * 2 + col - 1;
if let Some(t) = instance.terminals.get(new_idx) {
instance.focused_terminal_id = Some(t.focus_id);
Focus::new_for_id(t.focus_id).request_focus();
}
}
}
}
pub fn focus_terminal_down(&mut self, instance_id: usize) {
if let Some(instance) = self.instances.iter_mut().find(|i| i.id == instance_id) {
let Some(focus_id) = instance.focused_terminal_id else {
return;
};
let Some(idx) = instance.terminal_index_by_focus_id(focus_id) else {
return;
};
let row = idx / 2;
let col = idx % 2;
if row < 1 {
let new_idx = (row + 1) * 2 + col;
if let Some(t) = instance.terminals.get(new_idx) {
instance.focused_terminal_id = Some(t.focus_id);
Focus::new_for_id(t.focus_id).request_focus();
}
}
}
}
pub fn focus_terminal_up(&mut self, instance_id: usize) {
if let Some(instance) = self.instances.iter_mut().find(|i| i.id == instance_id) {
let Some(focus_id) = instance.focused_terminal_id else {
return;
};
let Some(idx) = instance.terminal_index_by_focus_id(focus_id) else {
return;
};
let row = idx / 2;
let col = idx % 2;
if row > 0 {
let new_idx = (row - 1) * 2 + col;
if let Some(t) = instance.terminals.get(new_idx) {
instance.focused_terminal_id = Some(t.focus_id);
Focus::new_for_id(t.focus_id).request_focus();
}
}
}
}
}
#[derive(PartialEq, Eq, Clone, Debug, Hash)]
pub enum AppChannel {
App,
Instance(usize),
Instances,
}
impl RadioChannel<AppState> for AppChannel {
fn derive_channel(self, state: &AppState) -> Vec<Self> {
match self {
AppChannel::App => {
let mut channels = vec![AppChannel::App];
for instance in &state.instances {
channels.push(AppChannel::Instance(instance.id));
}
channels
}
AppChannel::Instances => {
let mut channels = vec![AppChannel::Instances];
for instance in &state.instances {
channels.push(AppChannel::Instance(instance.id));
}
channels
}
AppChannel::Instance(id) => vec![AppChannel::Instance(id)],
}
}
}
pub fn spawn_thinking_watcher(
mut station: RadioStation<AppState, AppChannel>,
instance_id: usize,
terminal_index: usize,
handle: TerminalHandle,
) {
spawn_forever(async move {
loop {
let closed = handle.closed();
let output = handle.output_received();
futures_util::pin_mut!(closed);
futures_util::pin_mut!(output);
match futures_util::future::select(output, closed).await {
futures_util::future::Either::Right(_) => break,
futures_util::future::Either::Left(_) => {}
}
if handle.last_write_elapsed() < Duration::from_secs(1) {
continue;
}
if let Some(term) = station
.write_channel(AppChannel::Instance(instance_id))
.instances
.iter_mut()
.find(|i| i.id == instance_id)
.and_then(|inst| inst.terminals.get_mut(terminal_index))
{
term.is_thinking = true;
}
loop {
Timer::after(Duration::from_secs(1)).await;
let mut fut = std::pin::pin!(handle.output_received());
let has_output =
poll_fn(|cx| std::task::Poll::Ready(fut.as_mut().poll(cx).is_ready())).await;
if !has_output {
break;
}
}
if let Some(term) = station
.write_channel(AppChannel::Instance(instance_id))
.instances
.iter_mut()
.find(|i| i.id == instance_id)
.and_then(|inst| inst.terminals.get_mut(terminal_index))
{
term.is_thinking = false;
}
}
});
}