Compare commits

..

No commits in common. "main" and "v0.1.0" have entirely different histories.
main ... v0.1.0

42 changed files with 664 additions and 4248 deletions

1
.gitignore vendored
View File

@ -1,3 +1,2 @@
target/
.cuddle/
.DS_Store

View File

@ -6,91 +6,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.2.3] - 2024-09-26
### Added
- add update command
- only do clone if not exists
### Fixed
- *(deps)* update rust crate async-trait to v0.1.83
- *(deps)* update rust crate octocrab to 0.40.0
## [0.2.2] - 2024-09-23
### Other
- add docs
main@origin
- add license
- update to gitea-client
- add publish
- *(release)* 0.2.1
## [0.2.1] - 2024-09-23
### Added
- use termwiz as backend as that enables a ptty, which can be cleaned up nicely
- add errout for interactive for script support and atty for clean output
- add clone spinner
- add spinner around download
- spawn a subshell for session
- implement git clone
- include vhs demo
- add interactive search
- implement naive fuzzy matcher
### Fixed
- *(deps)* update tokio-prost monorepo to v0.13.3
- *(deps)* update rust crate bytes to v1.7.2
### Other
- update gif to include spinner
- clean up ui
- build in cuddle instead of vhs
- build first then run
- clear screen after build
- fix warnings
- update theme for vhs
- *(deps)* update rust crate clap to v4.5.18
- *(deps)* update rust crate pretty_assertions to v1.4.1
- refactor fuzzy match into own function
- cleanup warnings
- move fuzzy search out of command
- refactor/matcher move to a separate file
- move fuzzy search out of command
- Actually add fuzzy matcher
- extract matcher
- update dependencies
- *(deps)* update rust crate anyhow to v1.0.89
## [0.2.0] - 2024-09-14
### Added
- add cache get
- send out wait
- add cache
- add settings config
- add github fetch prs refactoring
- gitea able to pull repositories
- add config
### Docs
- add readme
### Fixed
- don't have to use user for basic auth
### Other
- removed unused code
- move projects list into separate file
- separate files
- move config out
- remove unused libraries
## [0.1.0] - 2024-09-12
### Added

2270
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -3,13 +3,15 @@ members = ["crates/*"]
resolver = "2"
[workspace.package]
version = "0.2.3"
version = "0.1.0"
[workspace.dependencies]
gitnow = { path = "crates/gitnow" }
anyhow = { version = "1" }
tokio = { version = "1", features = ["full"] }
tracing = { version = "0.1", features = ["log"] }
tracing-subscriber = { version = "0.3.18", features = ["env-filter"] }
tracing-subscriber = { version = "0.3.18" }
clap = { version = "4", features = ["derive", "env"] }
dotenv = { version = "0.15" }
axum = { version = "0.7" }

View File

@ -1,38 +1,4 @@
# Git Now
Git Now is a utility for easily navigating git projects from common upstream providers. Search, Download, and Enter projects as quickly as you can type.
![example gif](./assets/gifs/example.gif)
## Installation
```bash
cargo (b)install gitnow
# You can either use gitnow directly (and use spawned shell sessions)
gitnow
# Or install gitnow scripts (in your .bashrc, .zshrc) this will use native shell commands to move you around
eval $(gitnow init zsh)
git-now # Long
gn # Short alias
```
## Reasoning
How many steps do you normally do to download a project?
1. Navigate to github.com
2. Search in your org for the project
3. Find the clone url
4. Navigate to your local github repositories path
5. Git clone `<project>`
6. Enter new project directory
A power user can of course use `gh repo clone` to skip a few steps.
With gitnow
1. `git now`
2. Enter parts of the project name and press enter
3. Your project is automatically downloaded if it doesn't exist in an opinionated path dir, and move you there.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 452 KiB

View File

@ -1,10 +0,0 @@
version: v2
managed:
enabled: true
plugins:
# dependencies
- remote: buf.build/community/neoeinstein-prost
out: crates/gitnow/src/gen
inputs:
- directory: crates/gitnow/proto

View File

@ -1,4 +0,0 @@
version: v2
modules:
- path: proto
name: buf.build/noschemaplz/gitnow

1
crates/gitnow/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/target

View File

@ -1,15 +1,8 @@
[package]
name = "gitnow"
description = "Git Now is a utility for easily navigating git projects from common upstream providers. Search, Download, and Enter projects as quickly as you can type."
edition = "2021"
readme = "../../README.md"
repository = "https://github.com/kjuulh/gitnow"
homepage = "https://gitnow-client.prod.kjuulh.app"
license = "MIT"
version.workspace = true
publish = true
[dependencies]
anyhow.workspace = true
@ -18,27 +11,9 @@ tracing.workspace = true
tracing-subscriber.workspace = true
clap.workspace = true
dotenv.workspace = true
axum.workspace = true
serde = { version = "1.0.197", features = ["derive"] }
sqlx = { version = "0.7.3", features = ["runtime-tokio", "tls-rustls", "postgres", "uuid", "time"] }
uuid = { version = "1.7.0", features = ["v4"] }
async-trait = "0.1.82"
toml = "0.8.19"
gitea-client = { version = "1.22.1" }
url = "2.5.2"
octocrab = "0.41.0"
dirs = "5.0.1"
prost = "0.13.2"
prost-types = "0.13.2"
bytes = "1.7.1"
nucleo-matcher = "0.3.1"
ratatui = { version = "0.29.0", features = ["termwiz"] }
crossterm = { version = "0.28.0", features = ["event-stream"] }
futures = "0.3.30"
termwiz = "0.22.0"
[dev-dependencies]
pretty_assertions = "1.4.0"
[features]
example = []
tower-http = { version = "0.5.2", features = ["cors", "trace"] }

View File

@ -1,22 +0,0 @@
function git-now {
# Run an update in the background
(
nohup gitnow update > /dev/null 2>&1 &
)
# Find the repository of choice
choice=$(gitnow "$@" --no-shell)
if [[ $? -ne 0 ]]; then
return $?
fi
# Enter local repository path
cd "$(echo "$choice" | tail --lines 1)"
}
function gn {
git-now "$@"
if [[ $? -ne 0 ]]; then
return $?
fi
}

View File

@ -1,14 +0,0 @@
syntax = "proto3";
package gitnow.v1;
message Repositories {
repeated Repository repositories = 1;
}
message Repository {
string provider = 1;
string owner = 2;
string repo_name= 3;
string ssh_url = 4;
}

View File

@ -1,12 +0,0 @@
use crate::config::Config;
#[derive(Debug)]
pub struct App {
pub config: Config,
}
impl App {
pub async fn new_static(config: Config) -> anyhow::Result<&'static App> {
Ok(Box::leak(Box::new(App { config })))
}
}

View File

@ -1,126 +0,0 @@
use std::path::PathBuf;
use anyhow::Context;
use tokio::io::AsyncWriteExt;
use crate::{app::App, cache_codec::CacheCodecApp, config::Config, git_provider::Repository};
pub struct Cache {
app: &'static App,
}
impl Cache {
pub fn new(app: &'static App) -> Self {
Self { app }
}
pub async fn update(&self, repositories: &[Repository]) -> anyhow::Result<()> {
tracing::debug!(repository_len = repositories.len(), "storing repositories");
let location = self.app.config.get_cache_file_location()?;
tracing::trace!("found cache location: {}", location.display());
if let Some(parent) = location.parent() {
tokio::fs::create_dir_all(parent).await?;
}
let cache_content = self
.app
.cache_codec()
.serialize_repositories(repositories)?;
let mut cache_file = tokio::fs::File::create(location)
.await
.context("failed to create cache file")?;
cache_file
.write_all(&cache_content)
.await
.context("failed to write cache content to file")?;
Ok(())
}
pub async fn get(&self) -> anyhow::Result<Option<Vec<Repository>>> {
tracing::debug!("fetching repositories");
let location = self.app.config.get_cache_file_location()?;
if !location.exists() {
tracing::debug!(
location = location.display().to_string(),
"cache doesn't exist"
);
return Ok(None);
}
if let Some(cache_duration) = self.app.config.settings.cache.duration.get_duration() {
let metadata = tokio::fs::metadata(&location).await?;
if let Ok(file_modified_last) = metadata
.modified()
.context("failed to get modified date")
.inspect_err(|e| {
tracing::warn!(
"could not get valid metadata from file, cache will be reused: {}",
e
);
})
.and_then(|m| {
m.elapsed()
.context("failed to get elapsed from file")
.inspect_err(|e| tracing::warn!("failed to get elapsed from system: {}", e))
})
{
tracing::trace!(
cache = file_modified_last.as_secs(),
expiry = cache_duration.as_secs(),
"checking if cache is valid"
);
if file_modified_last > cache_duration {
tracing::debug!("cache has expired");
return Ok(None);
}
}
}
let file = tokio::fs::read(&location).await?;
if file.is_empty() {
tracing::debug!("cache file appears to be empty");
return Ok(None);
}
let repos = match self.app.cache_codec().deserialize_repositories(file) {
Ok(repos) => repos,
Err(e) => {
tracing::warn!(error = e.to_string(), "failed to deserialize repositories");
return Ok(None);
}
};
Ok(Some(repos))
}
}
pub trait CacheApp {
fn cache(&self) -> Cache;
}
impl CacheApp for &'static App {
fn cache(&self) -> Cache {
Cache::new(self)
}
}
pub trait CacheConfig {
fn get_cache_location(&self) -> anyhow::Result<PathBuf>;
fn get_cache_file_location(&self) -> anyhow::Result<PathBuf>;
}
impl CacheConfig for Config {
fn get_cache_location(&self) -> anyhow::Result<PathBuf> {
Ok(self.settings.cache.location.clone().into())
}
fn get_cache_file_location(&self) -> anyhow::Result<PathBuf> {
Ok(self.get_cache_location()?.join("cache.proto"))
}
}

View File

@ -1,61 +0,0 @@
use std::io::Cursor;
use anyhow::Context;
use prost::Message;
use crate::{app::App, git_provider::Repository};
mod proto_codec {
include!("gen/gitnow.v1.rs");
}
pub struct CacheCodec {}
impl CacheCodec {
pub fn new() -> Self {
Self {}
}
pub fn serialize_repositories(&self, repositories: &[Repository]) -> anyhow::Result<Vec<u8>> {
let mut codec_repos = proto_codec::Repositories::default();
for repo in repositories.iter().cloned() {
codec_repos.repositories.push(proto_codec::Repository {
provider: repo.provider,
owner: repo.owner,
repo_name: repo.repo_name,
ssh_url: repo.ssh_url,
});
}
Ok(codec_repos.encode_to_vec())
}
pub fn deserialize_repositories(&self, content: Vec<u8>) -> anyhow::Result<Vec<Repository>> {
let codex_repos = proto_codec::Repositories::decode(&mut Cursor::new(content))
.context("failed to decode protobuf repositories")?;
let mut repos = Vec::new();
for codec_repo in codex_repos.repositories {
repos.push(Repository {
provider: codec_repo.provider,
owner: codec_repo.owner,
repo_name: codec_repo.repo_name,
ssh_url: codec_repo.ssh_url,
});
}
Ok(repos)
}
}
pub trait CacheCodecApp {
fn cache_codec(&self) -> CacheCodec;
}
impl CacheCodecApp for &'static App {
fn cache_codec(&self) -> CacheCodec {
CacheCodec::new()
}
}

View File

@ -1,3 +0,0 @@
pub mod root;
pub mod shell;
pub mod update;

View File

@ -1,179 +0,0 @@
use std::{collections::BTreeMap, io::IsTerminal};
use crate::{
app::App,
cache::CacheApp,
components::inline_command::InlineCommand,
fuzzy_matcher::{FuzzyMatcher, FuzzyMatcherApp},
git_clone::GitCloneApp,
git_provider::Repository,
interactive::InteractiveApp,
projects_list::ProjectsListApp,
shell::ShellApp,
};
#[derive(Debug, Clone)]
pub struct RootCommand {
app: &'static App,
}
impl RootCommand {
pub fn new(app: &'static App) -> Self {
Self { app }
}
pub async fn execute(
&mut self,
search: Option<impl Into<String>>,
cache: bool,
clone: bool,
shell: bool,
force_refresh: bool,
force_cache_update: bool,
) -> anyhow::Result<()> {
tracing::debug!("executing");
let repositories = if !force_cache_update {
if cache {
match self.app.cache().get().await? {
Some(repos) => repos,
None => {
tracing::info!("finding repositories...");
let repositories = self.app.projects_list().get_projects().await?;
self.app.cache().update(&repositories).await?;
repositories
}
}
} else {
self.app.projects_list().get_projects().await?
}
} else {
tracing::info!("forcing cache update...");
let repositories = self.app.projects_list().get_projects().await?;
self.app.cache().update(&repositories).await?;
repositories
};
let repo = match search {
Some(needle) => {
let matched_repos = self
.app
.fuzzy_matcher()
.match_repositories(&needle.into(), &repositories);
let repo = matched_repos
.first()
.ok_or(anyhow::anyhow!("failed to find repository"))?;
tracing::debug!("selected repo: {}", repo.to_rel_path().display());
repo.to_owned()
}
None => {
let repo = self
.app
.interactive()
.interactive_search(&repositories)?
.ok_or(anyhow::anyhow!("failed to find a repository"))?;
tracing::debug!("selected repo: {}", repo.to_rel_path().display());
repo
}
};
let project_path = self
.app
.config
.settings
.projects
.directory
.join(repo.to_rel_path());
if !project_path.exists() {
if clone {
let git_clone = self.app.git_clone();
if std::io::stdout().is_terminal() && shell {
let mut wrap_cmd =
InlineCommand::new(format!("cloning: {}", repo.to_rel_path().display()));
let repo = repo.clone();
wrap_cmd
.execute(move || async move {
git_clone.clone_repo(&repo, force_refresh).await?;
Ok(())
})
.await?;
} else {
eprintln!("cloning repository...");
git_clone.clone_repo(&repo, force_refresh).await?;
}
} else {
tracing::info!("skipping clone for repo: {}", &repo.to_rel_path().display());
}
} else {
tracing::info!("repository already exists");
}
if shell {
self.app.shell().spawn_shell(&repo).await?;
} else {
tracing::info!("skipping shell for repo: {}", &repo.to_rel_path().display());
println!(
"{}",
self.app
.config
.settings
.projects
.directory
.join(repo.to_rel_path())
.display()
);
}
Ok(())
}
}
trait StringExt {
fn as_str_vec(&self) -> Vec<&str>;
}
impl StringExt for Vec<String> {
fn as_str_vec(&self) -> Vec<&str> {
self.iter().map(|r| r.as_ref()).collect()
}
}
impl StringExt for Vec<&String> {
fn as_str_vec(&self) -> Vec<&str> {
self.iter().map(|r| r.as_ref()).collect()
}
}
pub trait RepositoryMatcher {
fn match_repositories(&self, pattern: &str, repositories: &[Repository]) -> Vec<Repository>;
}
impl RepositoryMatcher for FuzzyMatcher {
fn match_repositories(&self, pattern: &str, repositories: &[Repository]) -> Vec<Repository> {
let haystack = repositories
.iter()
.map(|r| (r.to_rel_path().display().to_string(), r))
.collect::<BTreeMap<_, _>>();
let haystack_keys = haystack.keys().collect::<Vec<_>>();
let haystack_keys = haystack_keys.as_str_vec();
let res = self.match_pattern(pattern, &haystack_keys);
let matched_repos = res
.into_iter()
.filter_map(|repo_key| haystack.get(repo_key).map(|r| (*r).to_owned()))
.collect::<Vec<_>>();
matched_repos
}
}

View File

@ -1,33 +0,0 @@
use zsh::ZshShell;
pub mod zsh;
#[derive(clap::Parser)]
pub struct Shell {
#[command(subcommand)]
shell: ShellSubcommands,
}
impl Shell {
pub async fn execute(&mut self) -> anyhow::Result<()> {
self.shell.execute().await?;
Ok(())
}
}
#[derive(clap::Subcommand)]
pub enum ShellSubcommands {
#[command()]
Zsh(ZshShell),
}
impl ShellSubcommands {
pub async fn execute(&mut self) -> anyhow::Result<()> {
match self {
ShellSubcommands::Zsh(zsh) => zsh.execute().await?,
}
Ok(())
}
}

View File

@ -1,12 +0,0 @@
#[derive(clap::Parser)]
pub struct ZshShell {}
const SCRIPT: &str = include_str!("../../../include/shell/zsh.sh");
impl ZshShell {
pub async fn execute(&mut self) -> anyhow::Result<()> {
println!("{}", SCRIPT);
Ok(())
}
}

View File

@ -1,14 +0,0 @@
use crate::{app::App, cache::CacheApp, projects_list::ProjectsListApp};
#[derive(clap::Parser)]
pub struct Update {}
impl Update {
pub async fn execute(&mut self, app: &'static App) -> anyhow::Result<()> {
let repositories = app.projects_list().get_projects().await?;
app.cache().update(&repositories).await?;
Ok(())
}
}

View File

@ -1,119 +0,0 @@
use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender};
pub mod inline_command;
pub mod spinner;
#[derive(Debug, PartialEq)]
pub enum Msg {
Quit,
Tick,
Success,
Failure(String),
}
pub struct Command {
func: Box<CommandFunc>,
}
impl Command {
pub fn new<T: FnOnce(&Dispatch) -> Option<Msg> + 'static>(f: T) -> Self {
Self { func: Box::new(f) }
}
pub fn execute(self, dispatch: &Dispatch) -> Option<Msg> {
(self.func)(dispatch)
}
}
pub trait IntoCommand {
fn into_command(self) -> Command;
}
impl IntoCommand for () {
fn into_command(self) -> Command {
Command::new(|_| None)
}
}
impl IntoCommand for Command {
fn into_command(self) -> Command {
self
}
}
impl IntoCommand for Msg {
fn into_command(self) -> Command {
Command::new(|_| Some(self))
}
}
type CommandFunc = dyn FnOnce(&Dispatch) -> Option<Msg>;
pub fn create_dispatch() -> (Dispatch, Receiver) {
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
(Dispatch { sender: tx }, Receiver { receiver: rx })
}
#[derive(Clone)]
pub struct Dispatch {
sender: UnboundedSender<Msg>,
}
impl Dispatch {
pub fn send(&self, msg: Msg) {
if let Err(e) = self.sender.send(msg) {
tracing::warn!("failed to send event: {}", e);
}
}
}
pub struct Receiver {
receiver: UnboundedReceiver<Msg>,
}
impl Receiver {
pub async fn next(&mut self) -> Option<Msg> {
self.receiver.recv().await
}
}
#[derive(Default)]
pub struct BatchCommand {
commands: Vec<Command>,
}
impl BatchCommand {
pub fn with(&mut self, cmd: impl IntoCommand) -> &mut Self {
self.commands.push(cmd.into_command());
self
}
}
impl IntoCommand for Vec<Command> {
fn into_command(self) -> Command {
BatchCommand::from(self).into_command()
}
}
impl From<Vec<Command>> for BatchCommand {
fn from(value: Vec<Command>) -> Self {
BatchCommand { commands: value }
}
}
impl IntoCommand for BatchCommand {
fn into_command(self) -> Command {
Command::new(|dispatch| {
for command in self.commands {
let msg = command.execute(dispatch);
if let Some(msg) = msg {
dispatch.send(msg);
}
}
None
})
}
}

View File

@ -1,195 +0,0 @@
use std::time::Duration;
use crossterm::event::{EventStream, KeyCode};
use futures::{FutureExt, StreamExt};
use ratatui::{
crossterm,
prelude::*,
widgets::{Block, Padding},
TerminalOptions, Viewport,
};
use crate::components::BatchCommand;
use super::{
create_dispatch,
spinner::{Spinner, SpinnerState},
Dispatch, IntoCommand, Msg, Receiver,
};
pub struct InlineCommand {
spinner: SpinnerState,
heading: String,
}
impl InlineCommand {
pub fn new(heading: impl Into<String>) -> Self {
Self {
spinner: SpinnerState::default(),
heading: heading.into(),
}
}
pub async fn execute<F, Fut>(&mut self, func: F) -> anyhow::Result<()>
where
F: FnOnce() -> Fut + Send + Sync + 'static,
Fut: futures::Future<Output = anyhow::Result<()>> + Send + 'static,
{
tracing::trace!("starting inline terminal");
let mut terminal = ratatui::init_with_options(TerminalOptions {
viewport: Viewport::Inline(3),
});
let (dispatch, mut receiver) = create_dispatch();
let mut event_stream = crossterm::event::EventStream::new();
let guard = TerminalGuard;
tokio::spawn({
let dispatch = dispatch.clone();
async move {
match func().await {
Ok(_) => dispatch.send(Msg::Success),
Err(e) => dispatch.send(Msg::Failure(e.to_string())),
}
}
});
loop {
if self
.update(&mut terminal, &dispatch, &mut receiver, &mut event_stream)
.await?
{
terminal.draw(|f| {
let buf = f.buffer_mut();
buf.reset();
})?;
break;
}
}
drop(guard);
println!();
Ok(())
}
async fn update(
&mut self,
terminal: &mut ratatui::Terminal<impl Backend>,
dispatch: &Dispatch,
receiver: &mut Receiver,
event_stream: &mut EventStream,
) -> anyhow::Result<bool> {
let input_event = event_stream.next().fuse();
let next_msg = receiver.next().fuse();
const FRAMES_PER_SECOND: f32 = 60.0;
const TICK_RATE: f32 = 20.0;
let period_frame = Duration::from_secs_f32(1.0 / FRAMES_PER_SECOND);
let mut interval_frames = tokio::time::interval(period_frame);
let period_tick = Duration::from_secs_f32(1.0 / TICK_RATE);
let mut interval_ticks = tokio::time::interval(period_tick);
let msg = tokio::select! {
_ = interval_frames.tick() => {
terminal.draw(|frame| self.draw(frame))?;
None
}
_ = interval_ticks.tick() => {
Some(Msg::Tick)
}
msg = next_msg => {
msg
}
input = input_event => {
if let Some(Ok(input)) = input {
self.handle_key_event(input)
} else {
None
}
}
};
if let Some(msg) = msg {
if Msg::Quit == msg {
return Ok(true);
}
let mut cmd = self.update_state(&msg);
loop {
let msg = cmd.into_command().execute(dispatch);
match msg {
Some(Msg::Quit) => return Ok(true),
Some(msg) => {
cmd = self.update_state(&msg);
}
None => break,
}
}
}
Ok(false)
}
fn draw(&mut self, frame: &mut Frame<'_>) {
let spinner = Spinner::new(Span::from(&self.heading));
let block = Block::new().padding(Padding::symmetric(2, 1));
StatefulWidget::render(
spinner.block(block),
frame.area(),
frame.buffer_mut(),
&mut self.spinner,
);
}
fn handle_key_event(&mut self, event: crossterm::event::Event) -> Option<Msg> {
if let crossterm::event::Event::Key(key) = event {
return match key.code {
KeyCode::Esc => Some(Msg::Quit),
KeyCode::Char('c') => Some(Msg::Quit),
_ => None,
};
}
None
}
fn update_state(&mut self, msg: &Msg) -> impl IntoCommand {
tracing::debug!("handling message: {:?}", msg);
let mut batch = BatchCommand::default();
match msg {
Msg::Quit => {}
Msg::Tick => {}
Msg::Success => return Msg::Quit.into_command(),
Msg::Failure(f) => {
tracing::error!("command failed: {}", f);
return Msg::Quit.into_command();
}
}
batch.with(self.spinner.update(msg));
batch.into_command()
}
}
#[derive(Default)]
struct TerminalGuard;
impl Drop for TerminalGuard {
fn drop(&mut self) {
tracing::trace!("restoring inline terminal");
ratatui::restore();
}
}

View File

@ -1,86 +0,0 @@
use std::time::{Duration, Instant};
use ratatui::{
text::{Line, Span},
widgets::{Block, Paragraph, StatefulWidget, Widget},
};
use super::{BatchCommand, IntoCommand, Msg};
pub struct Spinner<'a> {
span: Span<'a>,
block: Option<Block<'a>>,
}
impl<'a> Spinner<'a> {
pub fn new(span: Span<'a>) -> Self {
Self { span, block: None }
}
pub fn block(mut self, block: Block<'a>) -> Self {
self.block = Some(block);
self
}
}
impl<'a> StatefulWidget for Spinner<'a> {
type State = SpinnerState;
fn render(
self,
area: ratatui::prelude::Rect,
buf: &mut ratatui::prelude::Buffer,
state: &mut Self::State,
) {
let frame = MINIDOT_FRAMES
.get((state.frame) % MINIDOT_FRAMES.len())
.expect("to find a valid static frame");
let line = Line::from(vec![Span::from(*frame), Span::from(" "), self.span]);
let para = Paragraph::new(vec![line]);
let para = if let Some(block) = self.block {
para.block(block)
} else {
para
};
para.render(area, buf)
}
}
pub struct SpinnerState {
last_event: Instant,
interval: Duration,
frame: usize,
}
impl Default for SpinnerState {
fn default() -> Self {
Self {
last_event: Instant::now(),
interval: Duration::from_millis(1000 / 12),
frame: 0,
}
}
}
const MINIDOT_FRAMES: [&str; 10] = ["", "", "", "", "", "", "", "", "", ""];
impl SpinnerState {
pub fn update(&mut self, _msg: &Msg) -> impl IntoCommand {
let batch = BatchCommand::default();
let now = Instant::now();
if now.duration_since(self.last_event) >= self.interval {
self.last_event = now;
self.next_state();
}
batch
}
fn next_state(&mut self) {
self.frame = self.frame.wrapping_add(1);
}
}

View File

@ -1,397 +0,0 @@
use std::path::{Path, PathBuf};
use anyhow::Context;
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize, PartialEq)]
pub struct Config {
#[serde(default)]
pub settings: Settings,
#[serde(default)]
pub providers: Providers,
}
#[derive(Debug, Default, Serialize, Deserialize, PartialEq)]
pub struct Settings {
#[serde(default)]
pub projects: Projects,
#[serde(default)]
pub cache: Cache,
}
#[derive(Debug, Default, Serialize, Deserialize, PartialEq)]
pub struct Projects {
pub directory: ProjectLocation,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ProjectLocation(PathBuf);
impl From<PathBuf> for ProjectLocation {
fn from(value: PathBuf) -> Self {
Self(value)
}
}
impl From<ProjectLocation> for PathBuf {
fn from(value: ProjectLocation) -> Self {
value.0
}
}
impl Default for ProjectLocation {
fn default() -> Self {
let home = dirs::home_dir().unwrap_or_default();
Self(home.join("git"))
}
}
impl std::ops::Deref for ProjectLocation {
type Target = PathBuf;
fn deref(&self) -> &Self::Target {
&self.0
}
}
#[derive(Debug, Default, Serialize, Deserialize, PartialEq)]
pub struct Cache {
#[serde(default)]
pub location: CacheLocation,
#[serde(default)]
pub duration: CacheDuration,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct CacheLocation(PathBuf);
impl From<PathBuf> for CacheLocation {
fn from(value: PathBuf) -> Self {
Self(value)
}
}
impl From<CacheLocation> for PathBuf {
fn from(value: CacheLocation) -> Self {
value.0
}
}
impl Default for CacheLocation {
fn default() -> Self {
let home = dirs::home_dir().unwrap_or_default();
Self(home.join(".cache/gitnow"))
}
}
#[derive(Debug, Serialize, Deserialize, PartialEq)]
#[serde(untagged)]
pub enum CacheDuration {
Enabled(bool),
Precise {
#[serde(default)]
days: u64,
#[serde(default)]
hours: u64,
#[serde(default)]
minutes: u64,
},
}
impl CacheDuration {
pub fn get_duration(&self) -> Option<std::time::Duration> {
match self {
CacheDuration::Enabled(true) => CacheDuration::default().get_duration(),
CacheDuration::Enabled(false) => None,
CacheDuration::Precise {
days,
hours,
minutes,
} => Some(
std::time::Duration::from_days(*days)
+ std::time::Duration::from_hours(*hours)
+ std::time::Duration::from_mins(*minutes),
),
}
}
}
impl Default for CacheDuration {
fn default() -> Self {
Self::Precise {
days: 1,
hours: 0,
minutes: 0,
}
}
}
#[derive(Debug, Default, Serialize, Deserialize, PartialEq)]
pub struct Providers {
#[serde(default)]
pub github: Vec<GitHub>,
#[serde(default)]
pub gitea: Vec<Gitea>,
}
#[derive(Debug, Serialize, Deserialize, PartialEq)]
pub struct GitHub {
#[serde(default)]
pub url: Option<String>,
pub access_token: GitHubAccessToken,
#[serde(default)]
pub current_user: Option<String>,
#[serde(default)]
pub users: Vec<GitHubUser>,
#[serde(default)]
pub organisations: Vec<GitHubOrganisation>,
}
#[derive(Debug, Serialize, Deserialize, PartialEq)]
pub struct GitHubUser(String);
impl From<GitHubUser> for String {
fn from(value: GitHubUser) -> Self {
value.0
}
}
impl<'a> From<&'a GitHubUser> for &'a str {
fn from(value: &'a GitHubUser) -> Self {
value.0.as_str()
}
}
#[derive(Debug, Serialize, Deserialize, PartialEq)]
pub struct GitHubOrganisation(String);
impl From<GitHubOrganisation> for String {
fn from(value: GitHubOrganisation) -> Self {
value.0
}
}
impl<'a> From<&'a GitHubOrganisation> for &'a str {
fn from(value: &'a GitHubOrganisation) -> Self {
value.0.as_str()
}
}
#[derive(Debug, Serialize, Deserialize, PartialEq)]
pub struct Gitea {
pub url: String,
#[serde(default)]
pub access_token: Option<GiteaAccessToken>,
#[serde(default)]
pub current_user: Option<String>,
#[serde(default)]
pub users: Vec<GiteaUser>,
#[serde(default)]
pub organisations: Vec<GiteaOrganisation>,
}
#[derive(Debug, Serialize, Deserialize, PartialEq)]
#[serde(untagged)]
pub enum GiteaAccessToken {
Direct(String),
Env { env: String },
}
#[derive(Debug, Serialize, Deserialize, PartialEq)]
#[serde(untagged)]
pub enum GitHubAccessToken {
Direct(String),
Env { env: String },
}
#[derive(Debug, Serialize, Deserialize, PartialEq)]
pub struct GiteaUser(String);
impl From<GiteaUser> for String {
fn from(value: GiteaUser) -> Self {
value.0
}
}
impl<'a> From<&'a GiteaUser> for &'a str {
fn from(value: &'a GiteaUser) -> Self {
value.0.as_str()
}
}
#[derive(Debug, Serialize, Deserialize, PartialEq)]
pub struct GiteaOrganisation(String);
impl From<GiteaOrganisation> for String {
fn from(value: GiteaOrganisation) -> Self {
value.0
}
}
impl<'a> From<&'a GiteaOrganisation> for &'a str {
fn from(value: &'a GiteaOrganisation) -> Self {
value.0.as_str()
}
}
impl Config {
pub async fn from_file(file_path: &Path) -> anyhow::Result<Config> {
if !file_path.exists() {
if let Some(parent) = file_path.parent() {
tokio::fs::create_dir_all(parent).await?;
}
tokio::fs::File::create(file_path).await?;
}
let file_content = tokio::fs::read_to_string(file_path).await?;
Self::from_string(&file_content)
}
pub fn from_string(content: &str) -> anyhow::Result<Config> {
toml::from_str(content).context("failed to deserialize config file")
}
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn test_can_parse_config() -> anyhow::Result<()> {
let content = r#"
[settings]
projects = { directory = "git" }
[settings.cache]
location = ".cache/gitnow"
duration = { days = 2 }
[[providers.github]]
current_user = "kjuulh"
access_token = "some-token"
users = ["kjuulh"]
organisations = ["lunarway"]
[[providers.github]]
access_token = { env = "something" }
users = ["other"]
organisations = ["org"]
[[providers.gitea]]
url = "https://git.front.kjuulh.io/api/v1"
current_user = "kjuulh"
users = ["kjuulh"]
organisations = ["lunarway"]
[[providers.gitea]]
url = "https://git.front.kjuulh.io/api/v1"
users = ["other"]
organisations = ["org"]
[[providers.gitea]]
url = "https://git.front.kjuulh.io/api/v1"
"#;
let config = Config::from_string(content)?;
pretty_assertions::assert_eq!(
Config {
providers: Providers {
github: vec![
GitHub {
users: vec![GitHubUser("kjuulh".into())],
organisations: vec![GitHubOrganisation("lunarway".into())],
url: None,
access_token: GitHubAccessToken::Direct("some-token".into()),
current_user: Some("kjuulh".into())
},
GitHub {
users: vec![GitHubUser("other".into())],
organisations: vec![GitHubOrganisation("org".into())],
url: None,
access_token: GitHubAccessToken::Env {
env: "something".into()
},
current_user: None
}
],
gitea: vec![
Gitea {
url: "https://git.front.kjuulh.io/api/v1".into(),
users: vec![GiteaUser("kjuulh".into())],
organisations: vec![GiteaOrganisation("lunarway".into())],
access_token: None,
current_user: Some("kjuulh".into())
},
Gitea {
url: "https://git.front.kjuulh.io/api/v1".into(),
users: vec![GiteaUser("other".into())],
organisations: vec![GiteaOrganisation("org".into())],
access_token: None,
current_user: None
},
Gitea {
url: "https://git.front.kjuulh.io/api/v1".into(),
users: vec![],
organisations: vec![],
access_token: None,
current_user: None
},
]
},
settings: Settings {
cache: Cache {
location: PathBuf::from(".cache/gitnow").into(),
duration: CacheDuration::Precise {
days: 2,
hours: 0,
minutes: 0
}
},
projects: Projects {
directory: PathBuf::from("git").into()
}
}
},
config
);
Ok(())
}
#[test]
fn test_can_parse_empty_config() -> anyhow::Result<()> {
let content = r#"
# empty file
"#;
let config = Config::from_string(content)?;
assert_eq!(
Config {
providers: Providers {
github: vec![],
gitea: vec![]
},
settings: Settings {
cache: Cache::default(),
projects: Projects::default()
}
},
config
);
Ok(())
}
}

View File

@ -1,34 +0,0 @@
use nucleo_matcher::{pattern::Pattern, Matcher};
use crate::app::App;
pub struct FuzzyMatcher {}
impl FuzzyMatcher {
pub fn new() -> Self {
Self {}
}
pub fn match_pattern<'a>(&self, pattern: &'a str, items: &'a [&'a str]) -> Vec<&'a str> {
let pat = Pattern::new(
pattern,
nucleo_matcher::pattern::CaseMatching::Ignore,
nucleo_matcher::pattern::Normalization::Smart,
nucleo_matcher::pattern::AtomKind::Fuzzy,
);
let mut matcher = Matcher::new(nucleo_matcher::Config::DEFAULT);
let res = pat.match_list(items, &mut matcher);
res.into_iter().map(|(item, _)| *item).collect::<Vec<_>>()
}
}
pub trait FuzzyMatcherApp {
fn fuzzy_matcher(&self) -> FuzzyMatcher;
}
impl FuzzyMatcherApp for &'static App {
fn fuzzy_matcher(&self) -> FuzzyMatcher {
FuzzyMatcher::new()
}
}

View File

@ -1,21 +0,0 @@
// @generated
// This file is @generated by prost-build.
#[allow(clippy::derive_partial_eq_without_eq)]
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct Repositories {
#[prost(message, repeated, tag="1")]
pub repositories: ::prost::alloc::vec::Vec<Repository>,
}
#[allow(clippy::derive_partial_eq_without_eq)]
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct Repository {
#[prost(string, tag="1")]
pub provider: ::prost::alloc::string::String,
#[prost(string, tag="2")]
pub owner: ::prost::alloc::string::String,
#[prost(string, tag="3")]
pub repo_name: ::prost::alloc::string::String,
#[prost(string, tag="4")]
pub ssh_url: ::prost::alloc::string::String,
}
// @@protoc_insertion_point(module)

View File

@ -1,83 +0,0 @@
use crate::{app::App, git_provider::Repository};
#[derive(Debug, Clone)]
pub struct GitClone {
app: &'static App,
}
impl GitClone {
pub fn new(app: &'static App) -> Self {
Self { app }
}
pub async fn clone_repo(
&self,
repository: &Repository,
force_refresh: bool,
) -> anyhow::Result<()> {
let project_path = self
.app
.config
.settings
.projects
.directory
.join(repository.to_rel_path());
if force_refresh {
tokio::fs::remove_dir_all(&project_path).await?;
}
if project_path.exists() {
tracing::info!(
"project: {} already exists, skipping clone",
repository.to_rel_path().display()
);
return Ok(());
}
tracing::info!(
"cloning: {} into {}",
repository.ssh_url.as_str(),
&project_path.display().to_string(),
);
let mut cmd = tokio::process::Command::new("git");
cmd.args([
"clone",
repository.ssh_url.as_str(),
&project_path.display().to_string(),
]);
let output = cmd.output().await?;
match output.status.success() {
true => tracing::debug!(
"cloned {} into {}",
repository.ssh_url.as_str(),
&project_path.display().to_string(),
),
false => {
let stdout = std::str::from_utf8(&output.stdout).unwrap_or_default();
let stderr = std::str::from_utf8(&output.stderr).unwrap_or_default();
tracing::error!(
"failed to clone {} into {}, with output: {}, err: {}",
repository.ssh_url.as_str(),
&project_path.display().to_string(),
stdout,
stderr
)
}
}
Ok(())
}
}
pub trait GitCloneApp {
fn git_clone(&self) -> GitClone;
}
impl GitCloneApp for &'static App {
fn git_clone(&self) -> GitClone {
GitClone::new(self)
}
}

View File

@ -1,33 +0,0 @@
use std::path::PathBuf;
#[derive(Debug, Clone, PartialEq, PartialOrd)]
pub struct Repository {
pub provider: String,
pub owner: String,
pub repo_name: String,
pub ssh_url: String,
}
impl Repository {
pub fn to_rel_path(&self) -> PathBuf {
PathBuf::from(&self.provider)
.join(&self.owner)
.join(&self.repo_name)
}
}
pub trait VecRepositoryExt {
fn collect_unique(&mut self) -> &mut Self;
}
impl VecRepositoryExt for Vec<Repository> {
fn collect_unique(&mut self) -> &mut Self {
self.sort_by_key(|a| a.to_rel_path());
self.dedup_by_key(|a| a.to_rel_path());
self
}
}
pub mod gitea;
pub mod github;

View File

@ -1,232 +0,0 @@
use anyhow::Context;
use gitea_client::apis::configuration::Configuration;
use url::Url;
use crate::{app::App, config::GiteaAccessToken};
#[derive(Debug)]
pub struct GiteaProvider {
#[allow(dead_code)]
app: &'static App,
}
impl GiteaProvider {
pub fn new(app: &'static App) -> GiteaProvider {
GiteaProvider { app }
}
pub async fn list_repositories_for_current_user(
&self,
api: &str,
access_token: Option<&GiteaAccessToken>,
) -> anyhow::Result<Vec<super::Repository>> {
tracing::debug!("fetching gitea repositories for current user");
let config = self.get_config(api, access_token)?;
let mut repositories = Vec::new();
let mut page = 1;
loop {
let mut repos = self
.list_repositories_for_current_user_with_page(&config, page)
.await?;
if repos.is_empty() {
break;
}
repositories.append(&mut repos);
page += 1;
}
let provider = &Self::get_domain(api)?;
Ok(repositories
.into_iter()
.map(|repo| super::Repository {
provider: provider.into(),
owner: repo
.owner
.map(|user| user.login.unwrap_or_default())
.unwrap_or_default(),
repo_name: repo.name.unwrap_or_default(),
ssh_url: repo
.ssh_url
.expect("ssh url to be set for a gitea repository"),
})
.collect())
}
fn get_domain(api: &str) -> anyhow::Result<String> {
let url = Url::parse(api)?;
let provider = url.domain().unwrap_or("gitea");
Ok(provider.into())
}
async fn list_repositories_for_current_user_with_page(
&self,
config: &Configuration,
page: usize,
) -> anyhow::Result<Vec<gitea_client::models::Repository>> {
let repos =
gitea_client::apis::user_api::user_current_list_repos(config, Some(page as i32), None)
.await
.context("failed to fetch repos for users")?;
Ok(repos)
}
pub async fn list_repositories_for_user(
&self,
user: &str,
api: &str,
access_token: Option<&GiteaAccessToken>,
) -> anyhow::Result<Vec<super::Repository>> {
tracing::debug!(user = user, "fetching gitea repositories for user");
let config = self.get_config(api, access_token)?;
let mut repositories = Vec::new();
let mut page = 1;
loop {
let mut repos = self
.list_repositories_for_user_with_page(user, &config, page)
.await?;
if repos.is_empty() {
break;
}
repositories.append(&mut repos);
page += 1;
}
let provider = &Self::get_domain(api)?;
Ok(repositories
.into_iter()
.map(|repo| super::Repository {
provider: provider.into(),
owner: repo
.owner
.map(|user| user.login.unwrap_or_default())
.unwrap_or_default(),
repo_name: repo.name.unwrap_or_default(),
ssh_url: repo
.ssh_url
.expect("ssh url to be set for gitea repository"),
})
.collect())
}
pub async fn list_repositories_for_user_with_page(
&self,
user: &str,
config: &Configuration,
page: usize,
) -> anyhow::Result<Vec<gitea_client::models::Repository>> {
let repos =
gitea_client::apis::user_api::user_list_repos(config, user, Some(page as i32), None)
.await
.context("failed to fetch repos for users")?;
Ok(repos)
}
pub async fn list_repositories_for_organisation(
&self,
organisation: &str,
api: &str,
access_token: Option<&GiteaAccessToken>,
) -> anyhow::Result<Vec<super::Repository>> {
tracing::debug!(
organisation = organisation,
"fetching gitea repositories for organisation"
);
let config = self.get_config(api, access_token)?;
let mut repositories = Vec::new();
let mut page = 1;
loop {
let mut repos = self
.list_repositories_for_organisation_with_page(organisation, &config, page)
.await?;
if repos.is_empty() {
break;
}
repositories.append(&mut repos);
page += 1;
}
let provider = &Self::get_domain(api)?;
Ok(repositories
.into_iter()
.map(|repo| super::Repository {
provider: provider.into(),
owner: repo
.owner
.map(|user| user.login.unwrap_or_default())
.unwrap_or_default(),
repo_name: repo.name.unwrap_or_default(),
ssh_url: repo
.ssh_url
.expect("ssh url to be set for gitea repository"),
})
.collect())
}
pub async fn list_repositories_for_organisation_with_page(
&self,
organisation: &str,
config: &Configuration,
page: usize,
) -> anyhow::Result<Vec<gitea_client::models::Repository>> {
let repos = gitea_client::apis::organization_api::org_list_repos(
config,
organisation,
Some(page as i32),
None,
)
.await
.context("failed to fetch repos for users")?;
Ok(repos)
}
fn get_config(
&self,
api: &str,
access_token: Option<&GiteaAccessToken>,
) -> anyhow::Result<Configuration> {
let mut config = gitea_client::apis::configuration::Configuration::new();
config.base_path = api.into();
match access_token {
Some(GiteaAccessToken::Env { env }) => {
let token =
std::env::var(env).context(format!("{env} didn't have a valid value"))?;
config.basic_auth = Some(("".into(), Some(token)));
}
Some(GiteaAccessToken::Direct(var)) => {
config.bearer_access_token = Some(var.to_owned());
}
None => {}
}
Ok(config)
}
}
pub trait GiteaProviderApp {
fn gitea_provider(&self) -> GiteaProvider;
}
impl GiteaProviderApp for &'static App {
fn gitea_provider(&self) -> GiteaProvider {
GiteaProvider::new(self)
}
}

View File

@ -1,178 +0,0 @@
use octocrab::{models::Repository, params::repos::Sort, Octocrab, Page};
use crate::{app::App, config::GitHubAccessToken};
pub struct GitHubProvider {
#[allow(dead_code)]
app: &'static App,
}
impl GitHubProvider {
pub fn new(app: &'static App) -> GitHubProvider {
GitHubProvider { app }
}
pub async fn list_repositories_for_current_user(
&self,
url: Option<&String>,
access_token: &GitHubAccessToken,
) -> anyhow::Result<Vec<super::Repository>> {
tracing::debug!("fetching github repositories for current user");
let client = self.get_client(url, access_token)?;
let current_page = client
.current()
.list_repos_for_authenticated_user()
.type_("all")
.per_page(100)
.sort("full_name")
.send()
.await?;
let repos = self.unfold_pages(client, current_page).await?;
Ok(repos
.into_iter()
.filter_map(|repo| {
Some(super::Repository {
provider: self.get_url(url),
owner: repo.owner.map(|su| su.login)?,
repo_name: repo.name,
ssh_url: repo.ssh_url?,
})
})
.collect())
}
pub async fn list_repositories_for_user(
&self,
user: &str,
url: Option<&String>,
access_token: &GitHubAccessToken,
) -> anyhow::Result<Vec<super::Repository>> {
tracing::debug!(user = user, "fetching github repositories for user");
let client = self.get_client(url, access_token)?;
let current_page = client
.users(user)
.repos()
.r#type(octocrab::params::users::repos::Type::All)
.sort(Sort::FullName)
.per_page(100)
.send()
.await?;
let repos = self.unfold_pages(client, current_page).await?;
Ok(repos
.into_iter()
.filter_map(|repo| {
Some(super::Repository {
provider: self.get_url(url),
owner: repo.owner.map(|su| su.login)?,
repo_name: repo.name,
ssh_url: repo.ssh_url?,
})
})
.collect())
}
pub async fn list_repositories_for_organisation(
&self,
organisation: &str,
url: Option<&String>,
access_token: &GitHubAccessToken,
) -> anyhow::Result<Vec<super::Repository>> {
tracing::debug!(
organisation = organisation,
"fetching github repositories for organisation"
);
let client = self.get_client(url, access_token)?;
let current_page = client
.orgs(organisation)
.list_repos()
.repo_type(Some(octocrab::params::repos::Type::All))
.sort(Sort::FullName)
.per_page(100)
.send()
.await?;
let repos = self.unfold_pages(client, current_page).await?;
Ok(repos
.into_iter()
.filter_map(|repo| {
Some(super::Repository {
provider: self.get_url(url),
owner: repo.owner.map(|su| su.login)?,
repo_name: repo.name,
ssh_url: repo.ssh_url?,
})
})
.collect())
}
async fn unfold_pages(
&self,
client: octocrab::Octocrab,
page: Page<Repository>,
) -> anyhow::Result<Vec<Repository>> {
let mut current_page = page;
let mut repos = current_page.take_items();
while let Ok(Some(mut new_page)) = client.get_page(&current_page.next).await {
repos.extend(new_page.take_items());
current_page = new_page;
}
Ok(repos)
}
fn get_url(&self, url: Option<&String>) -> String {
let default_domain = "github.com".to_string();
if let Some(url) = url {
let Some(url) = url::Url::parse(url).ok() else {
return default_domain;
};
let Some(domain) = url.domain().map(|d| d.to_string()) else {
return default_domain;
};
domain
} else {
default_domain
}
}
fn get_client(
&self,
_url: Option<&String>,
access_token: &GitHubAccessToken,
) -> anyhow::Result<Octocrab> {
let client = octocrab::Octocrab::builder()
.personal_token(match access_token {
GitHubAccessToken::Direct(token) => token.to_owned(),
GitHubAccessToken::Env { env } => std::env::var(env)?,
})
.build()?;
Ok(client)
}
}
pub trait GitHubProviderApp {
fn github_provider(&self) -> GitHubProvider;
}
impl GitHubProviderApp for &'static App {
fn github_provider(&self) -> GitHubProvider {
GitHubProvider::new(self)
}
}

View File

@ -1,166 +0,0 @@
use app::App;
use ratatui::{prelude::*, Terminal};
use crate::git_provider::Repository;
pub struct Interactive {
app: &'static crate::app::App,
}
impl Interactive {
pub fn new(app: &'static crate::app::App) -> Self {
Self { app }
}
pub fn interactive_search(
&mut self,
repositories: &[Repository],
) -> anyhow::Result<Option<Repository>> {
let backend = TermwizBackend::new().map_err(|e| anyhow::anyhow!(e.to_string()))?;
let terminal = Terminal::new(backend)?;
let app_result = App::new(self.app, repositories).run(terminal);
app_result
}
}
pub trait InteractiveApp {
fn interactive(&self) -> Interactive;
}
impl InteractiveApp for &'static crate::app::App {
fn interactive(&self) -> Interactive {
Interactive::new(self)
}
}
mod app {
use ratatui::{
crossterm::event::{self, Event, KeyCode},
layout::{Constraint, Layout},
prelude::TermwizBackend,
style::{Style, Stylize},
text::{Line, Span},
widgets::{ListItem, ListState, Paragraph, StatefulWidget},
Frame, Terminal,
};
use crate::{
commands::root::RepositoryMatcher, fuzzy_matcher::FuzzyMatcherApp, git_provider::Repository,
};
pub struct App<'a> {
app: &'static crate::app::App,
repositories: &'a [Repository],
current_search: String,
matched_repos: Vec<Repository>,
list: ListState,
}
impl<'a> App<'a> {
pub fn new(app: &'static crate::app::App, repositories: &'a [Repository]) -> Self {
Self {
app,
repositories,
current_search: String::default(),
matched_repos: Vec::default(),
list: ListState::default(),
}
}
fn update_matched_repos(&mut self) {
let res = self
.app
.fuzzy_matcher()
.match_repositories(&self.current_search, self.repositories);
self.matched_repos = res;
if self.list.selected().is_none() {
self.list.select_first();
}
}
pub fn run(
mut self,
mut terminal: Terminal<TermwizBackend>,
) -> anyhow::Result<Option<Repository>> {
self.update_matched_repos();
loop {
terminal.draw(|frame| self.draw(frame))?;
if let Event::Key(key) = event::read()? {
match key.code {
KeyCode::Char(letter) => {
self.current_search.push(letter);
self.update_matched_repos();
}
KeyCode::Backspace => {
if !self.current_search.is_empty() {
let _ = self.current_search.remove(self.current_search.len() - 1);
self.update_matched_repos();
}
}
KeyCode::Esc => {
return Ok(None);
}
KeyCode::Enter => {
if let Some(selected) = self.list.selected() {
if let Some(repo) = self.matched_repos.get(selected).cloned() {
terminal.resize(ratatui::layout::Rect::ZERO)?;
return Ok(Some(repo));
}
}
return Ok(None);
}
KeyCode::Up => self.list.select_next(),
KeyCode::Down => self.list.select_previous(),
_ => {}
}
}
}
}
fn draw(&mut self, frame: &mut Frame) {
let vertical = Layout::vertical([Constraint::Percentage(100), Constraint::Min(1)]);
let [repository_area, input_area] = vertical.areas(frame.area());
let repos = &self.matched_repos;
let repo_items = repos
.iter()
.map(|r| r.to_rel_path().display().to_string())
.collect::<Vec<_>>();
let repo_list_items = repo_items
.into_iter()
.map(ListItem::from)
.collect::<Vec<_>>();
let repo_list = ratatui::widgets::List::new(repo_list_items)
.direction(ratatui::widgets::ListDirection::BottomToTop)
.scroll_padding(3)
.highlight_symbol("> ")
.highlight_spacing(ratatui::widgets::HighlightSpacing::Always)
.highlight_style(Style::default().bold().white());
StatefulWidget::render(
repo_list,
repository_area,
frame.buffer_mut(),
&mut self.list,
);
let input = Paragraph::new(Line::from(vec![
Span::from("> ").blue(),
Span::from(self.current_search.as_str()),
Span::from(" ").on_white(),
]));
frame.render_widget(input, input_area);
}
}
}

View File

@ -1,106 +1,28 @@
#![feature(duration_constructors)]
use std::path::PathBuf;
use anyhow::Context;
use clap::{Parser, Subcommand};
use commands::{root::RootCommand, shell::Shell, update::Update};
use config::Config;
use tracing::level_filters::LevelFilter;
use tracing_subscriber::EnvFilter;
mod app;
mod cache;
mod cache_codec;
mod commands;
mod components;
mod config;
mod fuzzy_matcher;
mod git_clone;
mod git_provider;
mod interactive;
mod projects_list;
mod shell;
#[derive(Parser)]
#[command(author, version, about, long_about = Some("Navigate git projects at the speed of thought"))]
#[command(author, version, about, long_about = None, subcommand_required = true)]
struct Command {
#[command(subcommand)]
command: Option<Commands>,
#[arg()]
search: Option<String>,
#[arg(long = "no-cache", default_value = "false")]
no_cache: bool,
#[arg(long = "no-clone", default_value = "false")]
no_clone: bool,
#[arg(long = "no-shell", default_value = "false")]
no_shell: bool,
#[arg(long = "force-refresh", default_value = "false")]
force_refresh: bool,
#[arg(long = "force-cache-update", default_value = "false")]
force_cache_update: bool,
}
#[derive(Subcommand)]
enum Commands {
Init(Shell),
Update(Update),
Hello {},
}
const DEFAULT_CONFIG_PATH: &str = ".config/gitnow/gitnow.toml";
#[tokio::main]
async fn main() -> anyhow::Result<()> {
dotenv::dotenv().ok();
tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::builder()
.with_default_directive(LevelFilter::ERROR.into())
.from_env_lossy(),
)
.init();
let home =
std::env::var("HOME").context("HOME was not found, are you using a proper shell?")?;
let default_config_path = PathBuf::from(home).join(DEFAULT_CONFIG_PATH);
let config_path = std::env::var("GITNOW_CONFIG")
.map(PathBuf::from)
.unwrap_or(default_config_path);
let config = Config::from_file(&config_path).await?;
let app = app::App::new_static(config).await?;
tracing_subscriber::fmt::init();
let cli = Command::parse();
tracing::debug!("Starting cli");
match cli.command {
Some(cmd) => match cmd {
Commands::Init(mut shell) => {
shell.execute().await?;
}
Commands::Update(mut update) => {
update.execute(app).await?;
}
},
None => {
RootCommand::new(app)
.execute(
cli.search.as_ref(),
!cli.no_cache,
!cli.no_clone,
!cli.no_shell,
cli.force_refresh,
cli.force_cache_update,
)
.await?;
}
if let Some(Commands::Hello {}) = cli.command {
println!("Hello!")
}
Ok(())

View File

@ -1,135 +0,0 @@
#[cfg(not(feature = "example"))]
pub use implementation::*;
#[cfg(feature = "example")]
pub use example_projects::*;
use crate::app::App;
pub trait ProjectsListApp {
fn projects_list(&self) -> ProjectsList;
}
impl ProjectsListApp for &'static App {
fn projects_list(&self) -> ProjectsList {
ProjectsList::new(self)
}
}
mod implementation {
use crate::{
app::App,
git_provider::{
gitea::GiteaProviderApp, github::GitHubProviderApp, Repository, VecRepositoryExt,
},
};
pub struct ProjectsList {
app: &'static App,
}
impl ProjectsList {
pub fn new(app: &'static App) -> Self {
Self { app }
}
pub async fn get_projects(&self) -> anyhow::Result<Vec<Repository>> {
let mut repositories = Vec::new();
repositories.extend(self.get_gitea_projects().await?);
repositories.extend(self.get_github_projects().await?);
repositories.collect_unique();
Ok(repositories)
}
async fn get_gitea_projects(&self) -> anyhow::Result<Vec<Repository>> {
let gitea_provider = self.app.gitea_provider();
let mut repositories = Vec::new();
for gitea in self.app.config.providers.gitea.iter() {
if let Some(_user) = &gitea.current_user {
let mut repos = gitea_provider
.list_repositories_for_current_user(&gitea.url, gitea.access_token.as_ref())
.await?;
repositories.append(&mut repos);
}
for gitea_user in gitea.users.iter() {
let mut repos = gitea_provider
.list_repositories_for_user(
gitea_user.into(),
&gitea.url,
gitea.access_token.as_ref(),
)
.await?;
repositories.append(&mut repos);
}
for gitea_org in gitea.organisations.iter() {
let mut repos = gitea_provider
.list_repositories_for_organisation(
gitea_org.into(),
&gitea.url,
gitea.access_token.as_ref(),
)
.await?;
repositories.append(&mut repos);
}
}
Ok(repositories)
}
async fn get_github_projects(&self) -> anyhow::Result<Vec<Repository>> {
let github_provider = self.app.github_provider();
let mut repositories = Vec::new();
for github in self.app.config.providers.github.iter() {
if let Some(_user) = &github.current_user {
let mut repos = github_provider
.list_repositories_for_current_user(
github.url.as_ref(),
&github.access_token,
)
.await?;
repositories.append(&mut repos);
}
for github_user in github.users.iter() {
let mut repos = github_provider
.list_repositories_for_user(
github_user.into(),
github.url.as_ref(),
&github.access_token,
)
.await?;
repositories.append(&mut repos);
}
for github_org in github.organisations.iter() {
let mut repos = github_provider
.list_repositories_for_organisation(
github_org.into(),
github.url.as_ref(),
&github.access_token,
)
.await?;
repositories.append(&mut repos);
}
}
Ok(repositories)
}
}
}
#[cfg(feature = "example")]
mod example_projects;

View File

@ -1,84 +0,0 @@
use crate::{app::App, git_provider::Repository};
pub struct ProjectsList {}
impl ProjectsList {
pub fn new(_app: &'static App) -> Self {
Self {}
}
pub async fn get_projects(&self) -> anyhow::Result<Vec<Repository>> {
Ok(self.from_strings([
"github.com/kjuulh/gitnow",
"github.com/kjuulh/gitnow-client",
"github.com/kjuulh/crunch",
"git.front.kjuulh.io/kjuulh/gitnow",
"git.front.kjuulh.io/kjuulh/gitnow-client",
"git.front.kjuulh.io/kjuulh/cuddle",
"git.front.kjuulh.io/kjuulh/buckle",
"git.front.kjuulh.io/kjuulh/books",
"git.front.kjuulh.io/kjuulh/blog-deployment",
"git.front.kjuulh.io/kjuulh/blog",
"git.front.kjuulh.io/kjuulh/bitfield",
"git.front.kjuulh.io/kjuulh/bitebuds-deployment",
"git.front.kjuulh.io/kjuulh/bitebuds",
"git.front.kjuulh.io/kjuulh/beerday",
"git.front.kjuulh.io/kjuulh/bearing",
"git.front.kjuulh.io/kjuulh/basic-webserver",
"git.front.kjuulh.io/kjuulh/backup",
"git.front.kjuulh.io/kjuulh/backstage",
"git.front.kjuulh.io/kjuulh/autom8-calendar-integration",
"git.front.kjuulh.io/kjuulh/astronvim",
"git.front.kjuulh.io/kjuulh/artifacts",
"git.front.kjuulh.io/kjuulh/articles",
"git.front.kjuulh.io/kjuulh/acc-server",
"git.front.kjuulh.io/kjuulh/_cargo-index",
"git.front.kjuulh.io/keep-up/keep-up-example",
"git.front.kjuulh.io/keep-up/keep-up",
"git.front.kjuulh.io/experiments/wasm-bin",
"git.front.kjuulh.io/dotfiles/doom",
"git.front.kjuulh.io/danskebank/testssl.sh",
"git.front.kjuulh.io/clank/kubernetes-state",
"git.front.kjuulh.io/clank/kubernetes-init",
"git.front.kjuulh.io/clank/blog",
"git.front.kjuulh.io/cibus/deployments",
"git.front.kjuulh.io/butikkaerlighilsen/client",
"git.front.kjuulh.io/bevy/bevy",
"git.front.kjuulh.io/OpenFood/openfood",
]))
}
fn from_strings(
&self,
repos_into: impl IntoIterator<Item = impl Into<Repository>>,
) -> Vec<Repository> {
let repos = repos_into
.into_iter()
.map(|item| item.into())
.collect::<Vec<Repository>>();
repos
}
}
impl From<&str> for Repository {
fn from(value: &str) -> Self {
let values = value.split("/").collect::<Vec<_>>();
if values.len() != 3 {
panic!("value: '{value}' isn't a valid provider/owner/repository")
}
let (provider, owner, name) = (
values.get(0).unwrap(),
values.get(1).unwrap(),
values.get(2).unwrap(),
);
Self {
provider: provider.to_string(),
owner: owner.to_string(),
repo_name: name.to_string(),
ssh_url: format!("ssh://git@{provider}/{owner}/{name}.git"),
}
}
}

View File

@ -1,65 +0,0 @@
use anyhow::Context;
use crate::{app::App, git_provider::Repository};
pub struct Shell {
app: &'static App,
}
impl Shell {
pub fn new(app: &'static App) -> Self {
Self { app }
}
pub async fn spawn_shell(&self, repository: &Repository) -> anyhow::Result<()> {
let project_path = self
.app
.config
.settings
.projects
.directory
.join(repository.to_rel_path());
if !project_path.exists() {
anyhow::bail!(
"project path: {} does not exists, it is either a file, or hasn't been cloned",
project_path.display()
);
}
let shell = std::env::var("SHELL")
.context("failed to find SHELL variable, required for spawning embedded shells")?;
let mut shell_cmd = tokio::process::Command::new(shell);
shell_cmd.current_dir(project_path);
let mut process = shell_cmd.spawn().context("failed to spawn child session")?;
let status = process.wait().await?;
if !status.success() {
tracing::warn!(
"child session returned non-zero, or missing return code: {}",
status.code().unwrap_or_default()
);
anyhow::bail!(
"child shell session failed with exit: {}",
status.code().unwrap_or(-1)
);
} else {
tracing::debug!("child session returned 0 exit code");
}
Ok(())
}
}
pub trait ShellApp {
fn shell(&self) -> Shell;
}
impl ShellApp for &'static App {
fn shell(&self) -> Shell {
Shell::new(self)
}
}

View File

@ -13,13 +13,3 @@ please:
branch: "main"
settings:
api_url: "https://git.front.kjuulh.io"
actions:
rust:
scripts:
record:
type: shell
update-gifs:
type: shell
install:
type: shell

View File

@ -1,7 +0,0 @@
[[providers.github]]
users = ["kjuulh"]
organisations = ["lunarway"]
[[providers.gitea]]
users = ["kjuulh"]
organisation = ["noorgplease"]

View File

@ -1,5 +0,0 @@
#!/usr/bin/env zsh
set -e
cargo install --path crates/gitnow

View File

@ -1,13 +0,0 @@
#!/usr/bin/env zsh
set -e
# Loop through each file in the folder
for file in "vhs"/*; do
# Check if it is a file (not a directory)
if [[ -f "$file" ]]; then
echo "Recording: $file"
vhs "./$file"
fi
done

View File

@ -1,11 +0,0 @@
#!/usr/bin/env zsh
rm -r assets/gifs
set -e
cargo build --features example && clear
cuddle x record
mkdir -p assets/gifs
mv target/vhs/* assets/gifs

View File

@ -0,0 +1,15 @@
version: "3"
services:
crdb:
restart: 'always'
image: 'cockroachdb/cockroach:v23.1.14'
command: 'start-single-node --advertise-addr 0.0.0.0 --insecure'
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health?ready=1"]
interval: '10s'
timeout: '30s'
retries: 5
start_period: '20s'
ports:
- 8080:8080
- '26257:26257'

View File

@ -1,17 +0,0 @@
Output "target/vhs/example.gif"
Set Theme "Dracula"
Set Width 1200
Set Height 1000
Hide
Type "./target/debug/gitnow --no-cache --force-refresh"
Enter
Show
Sleep 2s
Type@500ms "bevy"
Sleep 1s
Enter
Sleep 10s
Type "echo 'I am now in bevy!'"
Enter
Sleep 5s
Sleep 2s