quinjet 0.0.49

A fast, live, keyboard-first Git source-control interface for the terminal
use std::collections::BTreeMap;
use std::io::{self, Write};
use std::path::{Path, PathBuf};

use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};

use crate::tabs::{TabId, TabInfo};

pub(crate) const MAX_SSH_MACHINES: usize = 16;
pub(crate) const SWITCH_EXIT_BASE: u8 = 80;
pub(crate) const SWITCH_NEW_TAB_EXIT_BASE: u8 = 96;
pub(crate) const SWITCH_TAB_EXIT_BASE: u8 = 112;
pub(crate) const OPEN_PROJECTS_ENV: &str = "QUINJET_OPEN_PROJECTS";
pub(crate) const HANDOFF_CONTEXT_PREFIX: &[u8] = b"\x1b]777;quinjet-context=";
pub(crate) const HANDOFF_CONTEXT_SUFFIX: u8 = 0x07;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum SshProjectOpenMode {
    Current,
    New,
    Activate,
}

impl SshProjectOpenMode {
    pub(crate) const fn environment_value(self) -> &'static str {
        match self {
            Self::Current => "current-tab",
            Self::New => "new-tab",
            Self::Activate => "activate-tab",
        }
    }

    pub(crate) fn from_environment() -> Option<Self> {
        match std::env::var(OPEN_PROJECTS_ENV).ok()?.as_str() {
            "current-tab" => Some(Self::Current),
            "new-tab" => Some(Self::New),
            "activate-tab" => Some(Self::Activate),
            _ => None,
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct SshSwitch {
    pub index: usize,
    pub mode: SshProjectOpenMode,
}

pub(crate) fn next_accessible_machine_index(
    machines: &[SshMachine],
    selected: usize,
) -> Option<usize> {
    machines
        .iter()
        .enumerate()
        .skip(selected.saturating_add(1))
        .chain(machines.iter().enumerate().take(selected))
        .find(|(_, machine)| machine.accessible)
        .map(|(index, _)| index)
}

pub(crate) fn previous_accessible_machine_index(
    machines: &[SshMachine],
    selected: usize,
) -> Option<usize> {
    machines
        .iter()
        .enumerate()
        .take(selected)
        .rev()
        .chain(
            machines
                .iter()
                .enumerate()
                .skip(selected.saturating_add(1))
                .rev(),
        )
        .find(|(_, machine)| machine.accessible)
        .map(|(index, _)| index)
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct SshMachine {
    pub target: String,
    pub folder: PathBuf,
    pub accessible: bool,
    pub uses: u64,
    pub local: bool,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct SshTab {
    pub(crate) id: TabId,
    pub(crate) machine: String,
    pub(crate) title: String,
    pub(crate) root: PathBuf,
}

#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct SshTabs {
    entries: Vec<SshTab>,
    active: Option<TabId>,
    active_by_machine: BTreeMap<String, TabId>,
    next_id: u64,
}

impl SshTabs {
    pub(crate) const fn active_id(&self) -> Option<TabId> {
        self.active
    }

    pub(crate) fn infos(&self) -> Vec<TabInfo> {
        self.entries
            .iter()
            .map(|tab| TabInfo {
                id: tab.id,
                title: tab.title.clone(),
                root: tab.root.clone(),
                machine: Some(tab.machine.clone()),
                active: self.active == Some(tab.id),
            })
            .collect()
    }

    pub(crate) fn entries_for_machine(&self, machine: &str) -> impl Iterator<Item = &SshTab> {
        self.entries
            .iter()
            .filter(move |tab| tab.machine == machine)
    }

    pub(crate) fn get(&self, id: TabId) -> Option<&SshTab> {
        self.entries.iter().find(|tab| tab.id == id)
    }

    pub(crate) fn id_for_root(&self, machine: &str, root: &Path) -> Option<TabId> {
        self.entries
            .iter()
            .find(|tab| tab.machine == machine && crate::git::support::same_path(&tab.root, root))
            .map(|tab| tab.id)
    }

    pub(crate) fn active_for_machine(&self, machine: &str) -> Option<TabId> {
        self.active_by_machine
            .get(machine)
            .copied()
            .filter(|id| self.get(*id).is_some_and(|tab| tab.machine == machine))
    }

    pub(crate) fn append(
        &mut self,
        machine: impl Into<String>,
        title: impl Into<String>,
        root: impl Into<PathBuf>,
    ) -> TabId {
        let id = TabId::new(self.next_id);
        self.next_id = self.next_id.wrapping_add(1);
        self.entries.push(SshTab {
            id,
            machine: machine.into(),
            title: title.into(),
            root: root.into(),
        });
        drop(self.activate(id));
        id
    }

    pub(crate) fn replace(
        &mut self,
        id: TabId,
        title: impl Into<String>,
        root: impl Into<PathBuf>,
    ) -> bool {
        let Some(tab) = self.entries.iter_mut().find(|tab| tab.id == id) else {
            return false;
        };
        tab.title = title.into();
        tab.root = root.into();
        true
    }

    pub(crate) fn activate(&mut self, id: TabId) -> Option<String> {
        let machine = self.get(id)?.machine.clone();
        self.active = Some(id);
        let _previous = self.active_by_machine.insert(machine.clone(), id);
        Some(machine)
    }

    pub(crate) fn reorder(&mut self, source: TabId, target: TabId) -> bool {
        let Some(source_index) = self.entries.iter().position(|tab| tab.id == source) else {
            return false;
        };
        let Some(target_index) = self.entries.iter().position(|tab| tab.id == target) else {
            return false;
        };
        if source_index == target_index {
            return true;
        }
        let tab = self.entries.remove(source_index);
        self.entries.insert(target_index, tab);
        true
    }

    pub(crate) fn close(&mut self, id: TabId) -> Option<SshTab> {
        let index = self.entries.iter().position(|tab| tab.id == id)?;
        let removed = self.entries.remove(index);
        self.active_by_machine.retain(|_, active| *active != id);
        if self.active == Some(id) {
            self.active = self
                .entries
                .get(index)
                .or_else(|| self.entries.last())
                .map(|tab| tab.id);
        }
        if let Some(active) = self.active {
            drop(self.activate(active));
        }
        let next_for_machine = self
            .entries_for_machine(&removed.machine)
            .next()
            .map(|tab| tab.id);
        if self.active_for_machine(&removed.machine).is_none()
            && let Some(next) = next_for_machine
        {
            let _previous = self.active_by_machine.insert(removed.machine.clone(), next);
        }
        Some(removed)
    }

    pub(crate) fn close_others(&mut self, id: TabId) -> bool {
        let Some(tab) = self.get(id).cloned() else {
            return false;
        };
        self.entries.retain(|candidate| candidate.id == id);
        self.active = Some(id);
        self.active_by_machine.clear();
        let _previous = self.active_by_machine.insert(tab.machine, id);
        true
    }

    pub(crate) fn close_all(&mut self) {
        self.entries.clear();
        self.active = None;
        self.active_by_machine.clear();
    }
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct SshContext {
    pub current: String,
    pub machines: Vec<SshMachine>,
    pub tabs: SshTabs,
}

impl SshContext {
    pub(crate) fn from_environment() -> Option<Self> {
        std::env::var("QUINJET_SSH_CONTEXT")
            .ok()
            .and_then(|value| serde_json::from_str(&value).ok())
    }

    pub(crate) fn adjacent_accessible_machine_index(&self, reverse: bool) -> Option<usize> {
        let current = self
            .machines
            .iter()
            .position(|machine| machine.target == self.current)?;
        if reverse {
            previous_accessible_machine_index(&self.machines, current)
        } else {
            next_accessible_machine_index(&self.machines, current)
        }
    }
}

pub(crate) fn emit_handoff_context(context: &SshContext) -> Result<()> {
    let payload = serde_json::to_vec(context).context("failed to serialize terminal handoff")?;
    let mut stdout = io::stdout().lock();
    stdout
        .write_all(HANDOFF_CONTEXT_PREFIX)
        .and_then(|()| stdout.write_all(&payload))
        .and_then(|()| stdout.write_all(&[HANDOFF_CONTEXT_SUFFIX]))
        .and_then(|()| stdout.flush())
        .context("failed to send terminal handoff")
}

pub(crate) fn switch_exit_code(request: SshSwitch) -> Option<u8> {
    let Ok(index) = u8::try_from(request.index) else {
        return None;
    };
    let base = match request.mode {
        SshProjectOpenMode::Current => SWITCH_EXIT_BASE,
        SshProjectOpenMode::New => SWITCH_NEW_TAB_EXIT_BASE,
        SshProjectOpenMode::Activate => SWITCH_TAB_EXIT_BASE,
    };
    (usize::from(index) < MAX_SSH_MACHINES).then_some(base.saturating_add(index))
}

pub(crate) fn switch_request(code: i32) -> Option<SshSwitch> {
    let code = u8::try_from(code).ok()?;
    let (base, mode) = if code >= SWITCH_TAB_EXIT_BASE {
        (SWITCH_TAB_EXIT_BASE, SshProjectOpenMode::Activate)
    } else if code >= SWITCH_NEW_TAB_EXIT_BASE {
        (SWITCH_NEW_TAB_EXIT_BASE, SshProjectOpenMode::New)
    } else {
        (SWITCH_EXIT_BASE, SshProjectOpenMode::Current)
    };
    let index = code.checked_sub(base)? as usize;
    (index < MAX_SSH_MACHINES).then_some(SshSwitch { index, mode })
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn switch_codes_cover_only_the_machine_limit() {
        let current = |index| SshSwitch {
            index,
            mode: SshProjectOpenMode::Current,
        };
        let new_tab = |index| SshSwitch {
            index,
            mode: SshProjectOpenMode::New,
        };
        let activate_tab = |index| SshSwitch {
            index,
            mode: SshProjectOpenMode::Activate,
        };
        assert_eq!(switch_exit_code(current(0)), Some(80));
        assert_eq!(switch_exit_code(current(15)), Some(95));
        assert_eq!(switch_exit_code(new_tab(0)), Some(96));
        assert_eq!(switch_exit_code(new_tab(15)), Some(111));
        assert_eq!(switch_exit_code(activate_tab(0)), Some(112));
        assert_eq!(switch_exit_code(activate_tab(15)), Some(127));
        assert_eq!(switch_exit_code(current(16)), None);
        assert_eq!(switch_request(80), Some(current(0)));
        assert_eq!(switch_request(95), Some(current(15)));
        assert_eq!(switch_request(96), Some(new_tab(0)));
        assert_eq!(switch_request(111), Some(new_tab(15)));
        assert_eq!(switch_request(112), Some(activate_tab(0)));
        assert_eq!(switch_request(127), Some(activate_tab(15)));
        assert_eq!(switch_request(79), None);
        assert_eq!(switch_request(128), None);
    }

    #[test]
    fn shared_tabs_keep_global_order_and_per_machine_selection() {
        let mut tabs = SshTabs::default();
        let local_one = tabs.append("macbook", "one", "/local/one");
        let remote = tabs.append("tof", "remote", "/remote/repo");
        let local_two = tabs.append("macbook", "two", "/local/two");

        assert_eq!(tabs.activate(local_one).as_deref(), Some("macbook"));
        assert_eq!(tabs.active_for_machine("macbook"), Some(local_one));
        assert_eq!(tabs.active_for_machine("tof"), Some(remote));
        assert!(tabs.reorder(local_two, remote));
        assert_eq!(
            tabs.infos()
                .into_iter()
                .map(|tab| (tab.id, tab.machine))
                .collect::<Vec<_>>(),
            vec![
                (local_one, Some("macbook".to_owned())),
                (local_two, Some("macbook".to_owned())),
                (remote, Some("tof".to_owned())),
            ]
        );
        assert_eq!(tabs.close(local_one).map(|tab| tab.id), Some(local_one));
        assert_eq!(tabs.active_id(), Some(local_two));
        assert_eq!(tabs.active_for_machine("macbook"), Some(local_two));
    }
}