Compare commits

...

49 Commits
v0.2.0 ... main

Author SHA1 Message Date
ab963600f3 fix(deps): update all dependencies
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-10-22 00:31:58 +00:00
1aab9c2970 chore(deps): update rust crate anyhow to v1.0.90
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-10-19 00:35:28 +00:00
9dfcb52010 fix(deps): update all dependencies
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-10-17 00:36:47 +00:00
659f2247ff fix(deps): update rust crate octocrab to v0.41.1
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-10-11 00:32:54 +00:00
76f82f9060 chore(deps): update rust crate clap to v4.5.20
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-10-09 00:34:52 +00:00
bdfd001458 fix(deps): update rust crate futures to v0.3.31
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-10-06 00:31:44 +00:00
f4a2b52c72 chore(deps): update rust crate clap to v4.5.19
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-10-02 00:34:42 +00:00
aa78933f0e fix(deps): update rust crate octocrab to 0.41.0
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-10-01 00:34:35 +00:00
b242128d52 chore(release): v0.2.3 (#12)
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
chore(release): 0.2.3

Co-authored-by: cuddle-please <bot@cuddle.sh>
Reviewed-on: #12
2024-09-26 21:41:11 +02:00
17cb06904f
feat: add update command
All checks were successful
continuous-integration/drone/push Build is passing
2024-09-26 21:36:03 +02:00
e3292b0c73 fix(deps): update rust crate async-trait to v0.1.83
Some checks failed
continuous-integration/drone/pr Build is failing
continuous-integration/drone/push Build is failing
2024-09-25 00:35:40 +00:00
cc70131101
feat: only do clone if not exists
All checks were successful
continuous-integration/drone/push Build is passing
2024-09-24 08:38:13 +02:00
784c7303a5 fix(deps): update rust crate octocrab to 0.40.0
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-09-24 00:35:26 +00:00
bf6d637095 chore(release): v0.2.2 (#10)
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
chore(release): 0.2.2

Co-authored-by: cuddle-please <bot@cuddle.sh>
Reviewed-on: #10
2024-09-23 22:44:13 +02:00
7231c85448
add docs
All checks were successful
continuous-integration/drone/push Build is passing
main@origin
2024-09-23 22:42:01 +02:00
b5c3c9bac9
chore: add license
Signed-off-by: kjuulh <contact@kjuulh.io>
2024-09-23 22:42:01 +02:00
1a2958a6e3
chore: update to gitea-client 2024-09-23 22:42:01 +02:00
1f5ad2a216
chore: add publish 2024-09-23 22:42:01 +02:00
cuddle-please
f117f74130 chore(release): 0.2.1 2024-09-23 22:39:38 +02:00
4c91bb7242 chore(release): v0.2.1 (#5)
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
chore(release): 0.2.1

Co-authored-by: cuddle-please <bot@cuddle.sh>
Reviewed-on: #5
2024-09-23 22:34:40 +02:00
348e448ce9
feat: use termwiz as backend as that enables a ptty, which can be cleaned up nicely
All checks were successful
continuous-integration/drone/push Build is passing
2024-09-23 22:16:19 +02:00
f0f81f8a0b
feat: add errout for interactive for script support and atty for clean output
All checks were successful
continuous-integration/drone/push Build is passing
2024-09-23 21:35:10 +02:00
c9aacf0ecd
feat: add clone spinner
All checks were successful
continuous-integration/drone/push Build is passing
2024-09-23 19:45:34 +02:00
866a8b4c52
chore: update gif to include spinner
All checks were successful
continuous-integration/drone/push Build is passing
2024-09-23 00:20:26 +02:00
5900482b56
feat: add spinner around download
Some checks failed
continuous-integration/drone/push Build is failing
2024-09-23 00:18:47 +02:00
96d97a8167
chore: clean up ui
All checks were successful
continuous-integration/drone/push Build is passing
2024-09-22 16:34:20 +02:00
c2faf6d0b6
feat: spawn a subshell for session
All checks were successful
continuous-integration/drone/push Build is passing
2024-09-22 16:25:44 +02:00
a330e4454e
feat: implement git clone
All checks were successful
continuous-integration/drone/push Build is passing
2024-09-22 15:58:28 +02:00
1eee1d9f3e
chore: build in cuddle instead of vhs
All checks were successful
continuous-integration/drone/push Build is passing
2024-09-22 15:11:23 +02:00
fbe030aba0
chore: build first then run
All checks were successful
continuous-integration/drone/push Build is passing
2024-09-22 15:08:29 +02:00
fc66692f37
chore: clear screen after build
All checks were successful
continuous-integration/drone/push Build is passing
2024-09-22 15:01:44 +02:00
bc4ebed1f7
chore: fix warnings
Some checks reported errors
continuous-integration/drone/push Build was killed
2024-09-22 14:59:44 +02:00
bee38a9d12
chore: update theme for vhs
All checks were successful
continuous-integration/drone/push Build is passing
2024-09-22 14:57:19 +02:00
e2be9ba59a
feat: include vhs demo
All checks were successful
continuous-integration/drone/push Build is passing
2024-09-22 14:55:41 +02:00
5401f3707d
feat: add interactive search
All checks were successful
continuous-integration/drone/push Build is passing
2024-09-22 14:09:10 +02:00
d0a5da0946 fix(deps): update tokio-prost monorepo to v0.13.3
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-09-22 00:39:49 +00:00
8410453921 chore(deps): update rust crate clap to v4.5.18
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-09-21 00:56:50 +00:00
c7b9f75a0b fix(deps): update rust crate bytes to v1.7.2
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-09-19 00:32:38 +00:00
43a7196cf8 chore(deps): update rust crate pretty_assertions to v1.4.1
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-09-16 00:29:45 +00:00
39e1fea36f chore: refactor fuzzy match into own function
All checks were successful
continuous-integration/drone/push Build is passing
2024-09-15 22:36:39 +02:00
c2dfd020bf chore: cleanup warnings
All checks were successful
continuous-integration/drone/push Build is passing
2024-09-15 22:12:42 +02:00
37ae70bc56 refactor: move fuzzy search out of command
All checks were successful
continuous-integration/drone/push Build is passing
2024-09-15 22:08:39 +02:00
95fa4128ca refactor/matcher move to a separate file 2024-09-15 22:08:39 +02:00
55fff9612e refactor: move fuzzy search out of command 2024-09-15 22:08:39 +02:00
102af558f5 Actually add fuzzy matcher 2024-09-15 22:08:39 +02:00
ff8103c805 refactor: extract matcher
All checks were successful
continuous-integration/drone/push Build is passing
2024-09-15 21:44:38 +02:00
6773122076 feat: implement naive fuzzy matcher
All checks were successful
continuous-integration/drone/push Build is passing
2024-09-15 21:28:29 +02:00
1520374a39 chore: update dependencies
All checks were successful
continuous-integration/drone/push Build is passing
2024-09-15 20:29:44 +02:00
fd6ffc9645 chore(deps): update rust crate anyhow to v1.0.89
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-09-15 04:30:37 +00:00
32 changed files with 2509 additions and 304 deletions

1
.gitignore vendored
View File

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

View File

@ -6,6 +6,67 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [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 ## [0.2.0] - 2024-09-14
### Added ### Added

1211
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -2,6 +2,24 @@
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. 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? How many steps do you normally do to download a project?
1. Navigate to github.com 1. Navigate to github.com

BIN
assets/gifs/example.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 452 KiB

View File

@ -1 +0,0 @@
/target

View File

@ -1,8 +1,15 @@
[package] [package]
name = "gitnow" 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" edition = "2021"
readme = "../../README.md"
repository = "https://github.com/kjuulh/gitnow"
homepage = "https://gitnow-client.prod.kjuulh.app"
license = "MIT"
version.workspace = true version.workspace = true
publish = true
[dependencies] [dependencies]
anyhow.workspace = true anyhow.workspace = true
@ -17,13 +24,21 @@ uuid = { version = "1.7.0", features = ["v4"] }
async-trait = "0.1.82" async-trait = "0.1.82"
toml = "0.8.19" toml = "0.8.19"
gitea-rs = { git = "https://git.front.kjuulh.io/kjuulh/gitea-rs", version = "1.22.1" } gitea-client = { version = "1.22.1" }
url = "2.5.2" url = "2.5.2"
octocrab = "0.39.0" octocrab = "0.41.0"
dirs = "5.0.1" dirs = "5.0.1"
prost = "0.13.2" prost = "0.13.2"
prost-types = "0.13.2" prost-types = "0.13.2"
bytes = "1.7.1" 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] [dev-dependencies]
pretty_assertions = "1.4.0" pretty_assertions = "1.4.0"
[features]
example = []

View File

@ -0,0 +1,22 @@
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 +1,3 @@
pub mod root; pub mod root;
pub mod shell;
pub mod update;

View File

@ -1,4 +1,16 @@
use crate::{app::App, cache::CacheApp, projects_list::ProjectsListApp}; 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)] #[derive(Debug, Clone)]
pub struct RootCommand { pub struct RootCommand {
@ -10,11 +22,20 @@ impl RootCommand {
Self { app } Self { app }
} }
#[tracing::instrument(skip(self))] pub async fn execute(
pub async fn execute(&mut self) -> anyhow::Result<()> { &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"); tracing::debug!("executing");
let repositories = match self.app.cache().get().await? { let repositories = if !force_cache_update {
if cache {
match self.app.cache().get().await? {
Some(repos) => repos, Some(repos) => repos,
None => { None => {
tracing::info!("finding repositories..."); tracing::info!("finding repositories...");
@ -24,14 +45,135 @@ impl RootCommand {
repositories 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
}; };
for repo in &repositories { let repo = match search {
//tracing::info!("repo: {}", repo.to_rel_path().display()); 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");
} }
tracing::info!("amount of repos fetched {}", repositories.len()); 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(()) 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

@ -0,0 +1,33 @@
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

@ -0,0 +1,12 @@
#[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

@ -0,0 +1,14 @@
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

@ -0,0 +1,119 @@
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

@ -0,0 +1,195 @@
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

@ -0,0 +1,86 @@
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

@ -14,10 +14,49 @@ pub struct Config {
#[derive(Debug, Default, Serialize, Deserialize, PartialEq)] #[derive(Debug, Default, Serialize, Deserialize, PartialEq)]
pub struct Settings { pub struct Settings {
#[serde(default)]
pub projects: Projects,
#[serde(default)] #[serde(default)]
pub cache: Cache, 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)] #[derive(Debug, Default, Serialize, Deserialize, PartialEq)]
pub struct Cache { pub struct Cache {
#[serde(default)] #[serde(default)]
@ -231,6 +270,9 @@ mod test {
#[test] #[test]
fn test_can_parse_config() -> anyhow::Result<()> { fn test_can_parse_config() -> anyhow::Result<()> {
let content = r#" let content = r#"
[settings]
projects = { directory = "git" }
[settings.cache] [settings.cache]
location = ".cache/gitnow" location = ".cache/gitnow"
duration = { days = 2 } duration = { days = 2 }
@ -316,6 +358,9 @@ mod test {
hours: 0, hours: 0,
minutes: 0 minutes: 0
} }
},
projects: Projects {
directory: PathBuf::from("git").into()
} }
} }
}, },
@ -340,7 +385,8 @@ mod test {
gitea: vec![] gitea: vec![]
}, },
settings: Settings { settings: Settings {
cache: Cache::default() cache: Cache::default(),
projects: Projects::default()
} }
}, },
config config

View File

@ -0,0 +1,34 @@
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

@ -0,0 +1,83 @@
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,5 +1,5 @@
use anyhow::Context; use anyhow::Context;
use gitea_rs::apis::configuration::Configuration; use gitea_client::apis::configuration::Configuration;
use url::Url; use url::Url;
use crate::{app::App, config::GiteaAccessToken}; use crate::{app::App, config::GiteaAccessToken};
@ -68,9 +68,9 @@ impl GiteaProvider {
&self, &self,
config: &Configuration, config: &Configuration,
page: usize, page: usize,
) -> anyhow::Result<Vec<gitea_rs::models::Repository>> { ) -> anyhow::Result<Vec<gitea_client::models::Repository>> {
let repos = let repos =
gitea_rs::apis::user_api::user_current_list_repos(config, Some(page as i32), None) gitea_client::apis::user_api::user_current_list_repos(config, Some(page as i32), None)
.await .await
.context("failed to fetch repos for users")?; .context("failed to fetch repos for users")?;
@ -125,9 +125,9 @@ impl GiteaProvider {
user: &str, user: &str,
config: &Configuration, config: &Configuration,
page: usize, page: usize,
) -> anyhow::Result<Vec<gitea_rs::models::Repository>> { ) -> anyhow::Result<Vec<gitea_client::models::Repository>> {
let repos = let repos =
gitea_rs::apis::user_api::user_list_repos(config, user, Some(page as i32), None) gitea_client::apis::user_api::user_list_repos(config, user, Some(page as i32), None)
.await .await
.context("failed to fetch repos for users")?; .context("failed to fetch repos for users")?;
@ -184,8 +184,8 @@ impl GiteaProvider {
organisation: &str, organisation: &str,
config: &Configuration, config: &Configuration,
page: usize, page: usize,
) -> anyhow::Result<Vec<gitea_rs::models::Repository>> { ) -> anyhow::Result<Vec<gitea_client::models::Repository>> {
let repos = gitea_rs::apis::organization_api::org_list_repos( let repos = gitea_client::apis::organization_api::org_list_repos(
config, config,
organisation, organisation,
Some(page as i32), Some(page as i32),
@ -202,7 +202,7 @@ impl GiteaProvider {
api: &str, api: &str,
access_token: Option<&GiteaAccessToken>, access_token: Option<&GiteaAccessToken>,
) -> anyhow::Result<Configuration> { ) -> anyhow::Result<Configuration> {
let mut config = gitea_rs::apis::configuration::Configuration::new(); let mut config = gitea_client::apis::configuration::Configuration::new();
config.base_path = api.into(); config.base_path = api.into();
match access_token { match access_token {
Some(GiteaAccessToken::Env { env }) => { Some(GiteaAccessToken::Env { env }) => {

View File

@ -1,10 +1,4 @@
use anyhow::Context; use octocrab::{models::Repository, params::repos::Sort, Octocrab, Page};
use octocrab::{
auth::Auth,
models::{hooks::Config, Repository},
params::repos::Sort,
NoSvc, Octocrab, Page,
};
use crate::{app::App, config::GitHubAccessToken}; use crate::{app::App, config::GitHubAccessToken};
@ -159,7 +153,7 @@ impl GitHubProvider {
fn get_client( fn get_client(
&self, &self,
url: Option<&String>, _url: Option<&String>,
access_token: &GitHubAccessToken, access_token: &GitHubAccessToken,
) -> anyhow::Result<Octocrab> { ) -> anyhow::Result<Octocrab> {
let client = octocrab::Octocrab::builder() let client = octocrab::Octocrab::builder()

View File

@ -0,0 +1,166 @@
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

@ -4,27 +4,53 @@ use std::path::PathBuf;
use anyhow::Context; use anyhow::Context;
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
use commands::root::RootCommand; use commands::{root::RootCommand, shell::Shell, update::Update};
use config::Config; use config::Config;
use tracing::level_filters::LevelFilter;
use tracing_subscriber::EnvFilter;
mod app; mod app;
mod cache; mod cache;
mod cache_codec; mod cache_codec;
mod commands; mod commands;
mod components;
mod config; mod config;
mod fuzzy_matcher;
mod git_clone;
mod git_provider; mod git_provider;
mod interactive;
mod projects_list; mod projects_list;
mod shell;
#[derive(Parser)] #[derive(Parser)]
#[command(author, version, about, long_about = Some("Navigate git projects at the speed of thought"))] #[command(author, version, about, long_about = Some("Navigate git projects at the speed of thought"))]
struct Command { struct Command {
#[command(subcommand)] #[command(subcommand)]
command: Option<Commands>, 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)] #[derive(Subcommand)]
enum Commands { enum Commands {
Hello {}, Init(Shell),
Update(Update),
} }
const DEFAULT_CONFIG_PATH: &str = ".config/gitnow/gitnow.toml"; const DEFAULT_CONFIG_PATH: &str = ".config/gitnow/gitnow.toml";
@ -32,7 +58,13 @@ const DEFAULT_CONFIG_PATH: &str = ".config/gitnow/gitnow.toml";
#[tokio::main] #[tokio::main]
async fn main() -> anyhow::Result<()> { async fn main() -> anyhow::Result<()> {
dotenv::dotenv().ok(); dotenv::dotenv().ok();
tracing_subscriber::fmt::init(); tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::builder()
.with_default_directive(LevelFilter::ERROR.into())
.from_env_lossy(),
)
.init();
let home = let home =
std::env::var("HOME").context("HOME was not found, are you using a proper shell?")?; std::env::var("HOME").context("HOME was not found, are you using a proper shell?")?;
@ -49,9 +81,25 @@ async fn main() -> anyhow::Result<()> {
tracing::debug!("Starting cli"); tracing::debug!("Starting cli");
match cli.command { match cli.command {
Some(_) => todo!(), Some(cmd) => match cmd {
Commands::Init(mut shell) => {
shell.execute().await?;
}
Commands::Update(mut update) => {
update.execute(app).await?;
}
},
None => { None => {
RootCommand::new(app).execute().await?; 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?;
} }
} }

View File

@ -1,15 +1,34 @@
use crate::{ #[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, app::App,
git_provider::{ git_provider::{
gitea::GiteaProviderApp, github::GitHubProviderApp, Repository, VecRepositoryExt, gitea::GiteaProviderApp, github::GitHubProviderApp, Repository, VecRepositoryExt,
}, },
}; };
pub struct ProjectsList { pub struct ProjectsList {
app: &'static App, app: &'static App,
} }
impl ProjectsList { impl ProjectsList {
pub fn new(app: &'static App) -> Self { pub fn new(app: &'static App) -> Self {
Self { app } Self { app }
} }
@ -73,7 +92,10 @@ impl ProjectsList {
for github in self.app.config.providers.github.iter() { for github in self.app.config.providers.github.iter() {
if let Some(_user) = &github.current_user { if let Some(_user) = &github.current_user {
let mut repos = github_provider let mut repos = github_provider
.list_repositories_for_current_user(github.url.as_ref(), &github.access_token) .list_repositories_for_current_user(
github.url.as_ref(),
&github.access_token,
)
.await?; .await?;
repositories.append(&mut repos); repositories.append(&mut repos);
@ -106,14 +128,8 @@ impl ProjectsList {
Ok(repositories) Ok(repositories)
} }
}
pub trait ProjectsListApp {
fn projects_list(&self) -> ProjectsList;
}
impl ProjectsListApp for &'static App {
fn projects_list(&self) -> ProjectsList {
ProjectsList::new(self)
} }
} }
#[cfg(feature = "example")]
mod example_projects;

View File

@ -0,0 +1,84 @@
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

@ -0,0 +1,65 @@
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

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

5
scripts/install.sh Executable file
View File

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

13
scripts/record.sh Executable file
View File

@ -0,0 +1,13 @@
#!/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

11
scripts/update-gifs.sh Executable file
View File

@ -0,0 +1,11 @@
#!/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

17
vhs/example.vhs Normal file
View File

@ -0,0 +1,17 @@
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