Compare commits
2 commits
21a549b19f
...
bee2d265d7
Author | SHA1 | Date | |
---|---|---|---|
bee2d265d7 | |||
fd8c865a27 |
24 changed files with 441 additions and 46 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -1,3 +1,4 @@
|
|||
/target
|
||||
/dist
|
||||
.env
|
||||
.vscode
|
131
src/git/history.rs
Normal file
131
src/git/history.rs
Normal file
|
@ -0,0 +1,131 @@
|
|||
use anyhow::Result;
|
||||
use git2::DiffLine;
|
||||
use serde::Serialize;
|
||||
use std::path::Path;
|
||||
use time::{
|
||||
format_description::well_known::{Iso8601, Rfc2822},
|
||||
OffsetDateTime,
|
||||
};
|
||||
|
||||
use super::ThreadSafeRepository;
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
pub struct LineChange {
|
||||
pub op: char,
|
||||
pub old_line: Option<u32>,
|
||||
pub new_line: Option<u32>,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
pub struct CommitInfo {
|
||||
pub oid: String,
|
||||
pub message: String,
|
||||
pub author: String,
|
||||
pub date: String,
|
||||
pub changes: Vec<LineChange>,
|
||||
}
|
||||
|
||||
impl ThreadSafeRepository {
|
||||
pub fn get_history(&self, path: &Path) -> Result<Vec<CommitInfo>> {
|
||||
let repository = self.repository()?;
|
||||
|
||||
let mut revwalk = repository.revwalk()?;
|
||||
revwalk.push_head()?;
|
||||
revwalk.set_sorting(git2::Sort::TIME | git2::Sort::REVERSE)?;
|
||||
|
||||
let mut history = Vec::new();
|
||||
let mut last_blob = None;
|
||||
for oid in revwalk {
|
||||
// Get commit
|
||||
let oid = oid?;
|
||||
let commit = repository.find_commit(oid)?;
|
||||
let tree = commit.tree()?;
|
||||
|
||||
// Check if file changed in this commit
|
||||
let entry = tree.get_path(path)?;
|
||||
if last_blob == Some(entry.id()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get changes
|
||||
let current = repository.find_blob(entry.id())?;
|
||||
let old = last_blob.and_then(|id| repository.find_blob(id).ok());
|
||||
|
||||
let mut changes = vec![];
|
||||
repository.diff_blobs(
|
||||
old.as_ref(),
|
||||
None,
|
||||
Some(¤t),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some(&mut |_, _, line| {
|
||||
changes.push(line.into());
|
||||
true
|
||||
}),
|
||||
)?;
|
||||
|
||||
// Write new blob id to compare against
|
||||
last_blob = Some(entry.id());
|
||||
|
||||
history.push(CommitInfo {
|
||||
oid: commit.id().to_string(),
|
||||
message: commit.message().unwrap_or_default().to_string(),
|
||||
author: commit.author().to_string(),
|
||||
date: OffsetDateTime::from_unix_timestamp(commit.time().seconds())
|
||||
.unwrap_or_else(|_| OffsetDateTime::now_utc())
|
||||
.format(&Iso8601::DEFAULT)
|
||||
.unwrap_or_default(),
|
||||
changes,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(history)
|
||||
}
|
||||
|
||||
pub fn get_file_at_commit(&self, path: &Path, oid: &str) -> Result<String> {
|
||||
// lol codewhisperer autocompleted all this, have fun
|
||||
let repository = self.repository()?;
|
||||
let commit = repository.find_commit(oid.parse()?)?;
|
||||
let tree = commit.tree()?;
|
||||
let entry = tree.get_path(path)?;
|
||||
let blob = repository.find_blob(entry.id())?;
|
||||
Ok(String::from_utf8_lossy(blob.content()).into_owned())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<DiffLine<'_>> for LineChange {
|
||||
fn from(value: DiffLine<'_>) -> Self {
|
||||
Self {
|
||||
op: value.origin(),
|
||||
old_line: value.old_lineno(),
|
||||
new_line: value.new_lineno(),
|
||||
content: String::from_utf8_lossy(value.content()).into_owned(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CommitInfo {
|
||||
pub fn diff(&self) -> Vec<String> {
|
||||
let mut ordered = self.changes.clone();
|
||||
ordered.sort_by(|a, b| {
|
||||
let line_a = a.old_line.or_else(|| a.new_line).unwrap_or_default();
|
||||
let line_b = b.old_line.or_else(|| b.new_line).unwrap_or_default();
|
||||
line_a.cmp(&line_b)
|
||||
});
|
||||
ordered
|
||||
.iter()
|
||||
.map(|change| format!("{} {}", change.op, change.content))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn date_human(&self) -> String {
|
||||
OffsetDateTime::parse(&self.date, &Iso8601::DEFAULT)
|
||||
.ok()
|
||||
.and_then(|date| date.format(&Rfc2822).ok())
|
||||
.unwrap_or_else(|| self.date.clone())
|
||||
}
|
||||
}
|
|
@ -3,6 +3,8 @@ use git2::{ErrorCode, Index, IndexAddOption, Oid, Repository, Signature};
|
|||
use std::path::{Path, PathBuf};
|
||||
use tracing::info;
|
||||
|
||||
pub mod history;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ThreadSafeRepository {
|
||||
pub path: PathBuf,
|
|
@ -1,6 +1,6 @@
|
|||
use super::{
|
||||
accept::{parse_accept, ResponseType},
|
||||
error::{AppError, ErrorInfo},
|
||||
error::{ErrorInfo, Result},
|
||||
};
|
||||
use askama::Template;
|
||||
use askama_axum::IntoResponse;
|
||||
|
@ -12,7 +12,7 @@ use axum::{
|
|||
};
|
||||
use serde_json::json;
|
||||
|
||||
pub type HandlerResponse = Result<askama_axum::Response, AppError>;
|
||||
pub type HandlerResponse = Result<askama_axum::Response>;
|
||||
|
||||
struct Response {
|
||||
html: String,
|
||||
|
|
|
@ -1,16 +1,18 @@
|
|||
use crate::git::{GitConfig, ThreadSafeRepository};
|
||||
use crate::http::response::response_interceptor;
|
||||
use anyhow::{anyhow, Result};
|
||||
use axum::middleware::from_fn;
|
||||
use bollard::Docker;
|
||||
use clap::Parser;
|
||||
use node::git::{GitConfig, ThreadSafeRepository};
|
||||
use std::{net::SocketAddr, path::PathBuf};
|
||||
use tokio::fs;
|
||||
|
||||
mod git;
|
||||
mod http;
|
||||
mod nix;
|
||||
mod node;
|
||||
mod route;
|
||||
mod stack;
|
||||
|
||||
/// GitOps+WebUI for arion-based stacks
|
||||
#[derive(Parser, Debug)]
|
||||
|
|
|
@ -1,6 +1,4 @@
|
|||
pub mod container;
|
||||
pub mod error;
|
||||
pub mod git;
|
||||
pub mod nix;
|
||||
pub mod stack;
|
||||
pub mod system;
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
use crate::{
|
||||
http::error::Result,
|
||||
node::container::{kill, remove, restart, start, stop},
|
||||
route::AppError,
|
||||
AppState,
|
||||
};
|
||||
use axum::{
|
||||
|
@ -17,7 +17,7 @@ macro_rules! container_command {
|
|||
($cmd: ident) => {
|
||||
move |Path(cont_name): Path<String>, State(state): State<AppState>| async move {
|
||||
$cmd(&state.docker, &cont_name).await?;
|
||||
Ok(Redirect::to("./")) as Result<Redirect, AppError>
|
||||
Ok(Redirect::to("./")) as Result<Redirect>
|
||||
}
|
||||
};
|
||||
}
|
||||
|
@ -41,7 +41,7 @@ pub(super) fn router() -> Router<AppState> {
|
|||
post(
|
||||
move |Path(cont_name): Path<String>, State(state): State<AppState>| async move {
|
||||
remove(&state.docker, &cont_name).await?;
|
||||
Ok(Redirect::to("/")) as Result<Redirect, AppError>
|
||||
Ok(Redirect::to("/")) as Result<Redirect>
|
||||
},
|
||||
),
|
||||
)
|
||||
|
|
|
@ -1,12 +1,8 @@
|
|||
use crate::{
|
||||
http::{
|
||||
error::AppError,
|
||||
response::{reply, HandlerResponse},
|
||||
},
|
||||
node::{
|
||||
http::error::AppError,
|
||||
http::response::{reply, HandlerResponse},
|
||||
node::system::{system_info, SystemInfo},
|
||||
stack::{list, NodeInfo},
|
||||
system::{system_info, SystemInfo},
|
||||
},
|
||||
AppState,
|
||||
};
|
||||
use askama::Template;
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
use axum::{extract::State, http::StatusCode};
|
||||
|
||||
use crate::{http::error::AppError, node::stack::compose, AppState};
|
||||
use crate::{http::error::Result, stack::compose, AppState};
|
||||
|
||||
pub(super) async fn check_stack_file(
|
||||
State(state): State<AppState>,
|
||||
body: String,
|
||||
) -> Result<StatusCode, AppError> {
|
||||
) -> Result<StatusCode> {
|
||||
compose::check(&state.arion_bin, &body).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
|
|
@ -4,8 +4,8 @@ use axum::{extract::State, response::Redirect, Form};
|
|||
use serde::Deserialize;
|
||||
|
||||
use crate::{
|
||||
http::error::AppError,
|
||||
node::stack::{compose, operation},
|
||||
http::error::Result,
|
||||
stack::{compose, operation},
|
||||
AppState,
|
||||
};
|
||||
|
||||
|
@ -21,7 +21,7 @@ pub(super) struct CreateStackForm {
|
|||
pub(super) async fn create_stack(
|
||||
State(state): State<AppState>,
|
||||
Form(form): Form<CreateStackForm>,
|
||||
) -> Result<Redirect, AppError> {
|
||||
) -> Result<Redirect> {
|
||||
// Make sure body is is ok
|
||||
let name = compose::check(&state.arion_bin, &form.source).await?;
|
||||
|
||||
|
|
|
@ -5,7 +5,11 @@ use axum::{
|
|||
response::Redirect,
|
||||
};
|
||||
|
||||
use crate::{http::error::AppError, node::stack::operation, AppState};
|
||||
use crate::{
|
||||
http::error::Result,
|
||||
stack::{self, operation},
|
||||
AppState,
|
||||
};
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "stack/delete-one.html")]
|
||||
|
@ -13,14 +17,21 @@ struct ConfirmDeleteTemplate {
|
|||
stack_name: String,
|
||||
}
|
||||
|
||||
pub(super) async fn confirm_deletion_page(Path(stack_name): Path<String>) -> impl IntoResponse {
|
||||
ConfirmDeleteTemplate { stack_name }
|
||||
pub(super) async fn confirm_deletion_page(
|
||||
Path(stack_name): Path<String>,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<impl IntoResponse> {
|
||||
let project_id = stack::list::stack_name(&state.stack_dir, &stack_name).await?;
|
||||
|
||||
Ok(ConfirmDeleteTemplate {
|
||||
stack_name: project_id,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) async fn delete_stack(
|
||||
Path(stack_name): Path<String>,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Redirect, AppError> {
|
||||
) -> Result<Redirect> {
|
||||
operation::remove(
|
||||
&state.stack_dir,
|
||||
&state.arion_bin,
|
||||
|
|
|
@ -6,7 +6,11 @@ use axum::{
|
|||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::{http::error::AppError, node::stack::arion, node::stack::compose, AppState};
|
||||
use crate::{
|
||||
http::error::{AppError, Result},
|
||||
stack::{arion, compose},
|
||||
AppState,
|
||||
};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(super) struct EditStackForm {
|
||||
|
@ -18,7 +22,7 @@ pub(super) async fn edit_stack(
|
|||
Path(stack_name): Path<String>,
|
||||
State(state): State<AppState>,
|
||||
Form(form): Form<EditStackForm>,
|
||||
) -> Result<Redirect, AppError> {
|
||||
) -> Result<Redirect> {
|
||||
let commit_message = if form.commit_message.trim().is_empty() {
|
||||
format!("Update {}", stack_name)
|
||||
} else {
|
||||
|
@ -39,20 +43,34 @@ pub(super) async fn edit_stack(
|
|||
// Make sure file is ok
|
||||
compose::check(&state.arion_bin, &source).await?;
|
||||
|
||||
edit_stack_int(state, &stack_name, &source, &commit_message).await?;
|
||||
|
||||
Ok(Redirect::to("./"))
|
||||
}
|
||||
|
||||
pub(super) async fn edit_stack_int(
|
||||
state: AppState,
|
||||
stack_name: &str,
|
||||
source: &str,
|
||||
commit_message: &str,
|
||||
) -> Result<()> {
|
||||
// Make sure file is ok
|
||||
compose::check(&state.arion_bin, &source).await?;
|
||||
|
||||
// Write compose file
|
||||
compose::write(&state.stack_dir, &stack_name, &source).await?;
|
||||
compose::write(&state.stack_dir, stack_name, source).await?;
|
||||
|
||||
// Git commit
|
||||
compose::commit(state.repository, &stack_name, &commit_message)?;
|
||||
compose::commit(state.repository, stack_name, commit_message)?;
|
||||
|
||||
// Update stack
|
||||
arion::command(
|
||||
&state.stack_dir,
|
||||
&stack_name,
|
||||
stack_name,
|
||||
&state.arion_bin,
|
||||
arion::StackCommand::Start,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Redirect::to("./"))
|
||||
Ok(())
|
||||
}
|
||||
|
|
75
src/route/stack/history.rs
Normal file
75
src/route/stack/history.rs
Normal file
|
@ -0,0 +1,75 @@
|
|||
use askama::Template;
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
response::Redirect,
|
||||
Form,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{
|
||||
git::history::CommitInfo,
|
||||
http::error::Result,
|
||||
http::response::{reply, HandlerResponse},
|
||||
node::nix::parse_arion_compose,
|
||||
stack::compose,
|
||||
AppState,
|
||||
};
|
||||
|
||||
use super::edit::edit_stack_int;
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "stack/history.html")]
|
||||
struct HistoryTemplate {
|
||||
stack_name: String,
|
||||
commits: Vec<CommitInfo>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(super) struct RestoreForm {
|
||||
oid: String,
|
||||
}
|
||||
|
||||
pub(super) async fn list(
|
||||
Path(stack_name): Path<String>,
|
||||
State(state): State<AppState>,
|
||||
) -> HandlerResponse {
|
||||
let compose_file = compose::get(&state.stack_dir, &stack_name).await?;
|
||||
let info = parse_arion_compose(&compose_file)?;
|
||||
|
||||
let git_compose_path = compose::path(&stack_name);
|
||||
let commits = state.repository.get_history(&git_compose_path)?;
|
||||
|
||||
let mut newest_first = commits.clone();
|
||||
newest_first.reverse();
|
||||
|
||||
reply(
|
||||
json!({
|
||||
"commits": commits,
|
||||
}),
|
||||
HistoryTemplate {
|
||||
stack_name: info.project,
|
||||
commits: newest_first,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) async fn restore(
|
||||
Path(stack_name): Path<String>,
|
||||
State(state): State<AppState>,
|
||||
Form(form): Form<RestoreForm>,
|
||||
) -> Result<Redirect> {
|
||||
let path = compose::path(&stack_name);
|
||||
let source = state.repository.get_file_at_commit(&path, &form.oid)?;
|
||||
let short_id = form.oid[..6].to_string();
|
||||
|
||||
edit_stack_int(
|
||||
state,
|
||||
&stack_name,
|
||||
&source,
|
||||
format!("Revert to {}", short_id).as_str(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Redirect::to(".."))
|
||||
}
|
|
@ -6,8 +6,8 @@ use axum::{
|
|||
};
|
||||
|
||||
use crate::{
|
||||
http::error::AppError,
|
||||
node::stack::arion::{command, StackCommand},
|
||||
http::error::Result,
|
||||
stack::arion::{command, StackCommand},
|
||||
AppState,
|
||||
};
|
||||
|
||||
|
@ -17,13 +17,14 @@ mod check;
|
|||
mod create;
|
||||
mod delete;
|
||||
mod edit;
|
||||
mod history;
|
||||
mod read;
|
||||
|
||||
macro_rules! stack_command {
|
||||
($cmd: expr) => {
|
||||
move |Path(stack_name): Path<String>, State(state): State<AppState>| async move {
|
||||
command(&state.stack_dir, &stack_name, &state.arion_bin, $cmd).await?;
|
||||
Ok(Redirect::to("./")) as Result<Redirect, AppError>
|
||||
Ok(Redirect::to("./")) as Result<Redirect>
|
||||
}
|
||||
};
|
||||
}
|
||||
|
@ -54,5 +55,6 @@ pub(super) fn router() -> Router<AppState> {
|
|||
"/:stack/delete",
|
||||
get(delete::confirm_deletion_page).post(delete::delete_stack),
|
||||
)
|
||||
//.route("/:stack/history", get(get_stack_history))
|
||||
.route("/:stack/history", get(history::list))
|
||||
.route("/:stack/history/revert", post(history::restore))
|
||||
}
|
||||
|
|
|
@ -4,14 +4,14 @@ use serde_json::json;
|
|||
|
||||
use crate::{
|
||||
http::response::{reply, HandlerResponse},
|
||||
node::{container::ContainerInfo, nix::parse_arion_compose, stack},
|
||||
AppState,
|
||||
node::container::ContainerInfo,
|
||||
node::nix::parse_arion_compose,
|
||||
stack, AppState,
|
||||
};
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "stack/get-one.html")]
|
||||
struct GetOneTemplate {
|
||||
stack_folder: String,
|
||||
stack_name: String,
|
||||
file_contents: String,
|
||||
containers: Vec<ContainerInfo>,
|
||||
|
@ -33,7 +33,6 @@ pub(super) async fn get_one(
|
|||
"containers": containers,
|
||||
}),
|
||||
GetOneTemplate {
|
||||
stack_folder: stack_name,
|
||||
stack_name: info.project,
|
||||
file_contents,
|
||||
containers: containers.iter().map(|c| c.clone().into()).collect(),
|
||||
|
|
|
@ -6,8 +6,9 @@ use tempfile::tempdir;
|
|||
use tokio::fs;
|
||||
|
||||
use crate::{
|
||||
git::ThreadSafeRepository,
|
||||
http::error::AppError,
|
||||
node::{error::StackError, git::ThreadSafeRepository, nix::parse_arion_compose},
|
||||
node::{error::StackError, nix::parse_arion_compose},
|
||||
};
|
||||
|
||||
use super::{arion, utils, COMPOSE_FILE, PACKAGE_CONTENTS, PACKAGE_FILE};
|
||||
|
@ -32,8 +33,8 @@ pub async fn write(base_dir: &Path, stack_name: &str, contents: &str) -> Result<
|
|||
}
|
||||
|
||||
pub fn commit(repository: ThreadSafeRepository, stack_name: &str, message: &str) -> Result<()> {
|
||||
let compose_path = format!("{}/{}", stack_name, COMPOSE_FILE);
|
||||
repository.commit_files(&[&PathBuf::from(compose_path)], message)?;
|
||||
let compose_path = path(stack_name);
|
||||
repository.commit_files(&[&compose_path], message)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
@ -70,3 +71,8 @@ pub async fn check(arion_bin: &Path, source: &str) -> crate::http::error::Result
|
|||
Ok(info.project)
|
||||
}
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
pub fn path(stack_name: &str) -> PathBuf {
|
||||
format!("{}/{}", stack_name, COMPOSE_FILE).into()
|
||||
}
|
|
@ -93,3 +93,10 @@ pub async fn containers(docker: &Docker, stack_name: &str) -> Result<Vec<Contain
|
|||
}))
|
||||
.await?)
|
||||
}
|
||||
|
||||
// Shortcut function to get a stack name from directory
|
||||
pub async fn stack_name(base_dir: &Path, folder: &str) -> Result<String> {
|
||||
let compose_file = compose::get(base_dir, folder).await?;
|
||||
let info = parse_arion_compose(&compose_file)?;
|
||||
Ok(info.project)
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
use serde::Serialize;
|
||||
|
||||
use super::container::ContainerInfo;
|
||||
use crate::node::container::ContainerInfo;
|
||||
|
||||
pub mod arion;
|
||||
pub mod compose;
|
|
@ -3,7 +3,7 @@ use futures_util::future::try_join;
|
|||
use std::path::{Path, PathBuf};
|
||||
use tokio::fs;
|
||||
|
||||
use crate::node::git::ThreadSafeRepository;
|
||||
use crate::git::ThreadSafeRepository;
|
||||
|
||||
use super::{arion, COMPOSE_FILE, PACKAGE_CONTENTS, PACKAGE_FILE};
|
||||
|
|
@ -100,6 +100,13 @@ button {
|
|||
color: var(--link-hover);
|
||||
border-color: var(--link-hover);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background-color: #323035;
|
||||
border-color: #3C393F;
|
||||
color: #6F6D78;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
table.table {
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>{% block title %}{{ title }} - staxman{% endblock %}</title>
|
||||
<title>{% block title %}{% endblock %} - staxman</title>
|
||||
<link rel="preconnect" href="https://rsms.me/">
|
||||
<link rel="stylesheet" href="https://rsms.me/inter/inter.css">
|
||||
<link rel="stylesheet" href="https://iosevka-webfonts.github.io/iosevka/iosevka.css">
|
||||
|
|
140
templates/stack/history.html
Normal file
140
templates/stack/history.html
Normal file
|
@ -0,0 +1,140 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Stack history for {{stack_name}}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<main>
|
||||
<header>
|
||||
<h1>Stack history for <span class="stack-name">{{stack_name}}</span></h1>
|
||||
<div id="page-actions">
|
||||
</div>
|
||||
</header>
|
||||
<section class="versions">
|
||||
{% for (i, commit) in commits.iter().enumerate() %}
|
||||
<article>
|
||||
<p>
|
||||
<time datetime="{{commit.date}}">{{commit.date_human()}}</time>
|
||||
<span class="commit_id">
|
||||
{{commit.oid[..7]}}
|
||||
</span>
|
||||
</p>
|
||||
<details open>
|
||||
<summary title="{{commit.message}} ({{commit.author}})">
|
||||
{{commit.message}}
|
||||
</summary>
|
||||
<code>
|
||||
{% for line in commit.diff() %}
|
||||
{% match line.chars().next() %}
|
||||
{% when Some with ('+') %}
|
||||
<pre class="add">{{line}}</pre>
|
||||
{% when Some with ('-') %}
|
||||
<pre class="del">{{line}}</pre>
|
||||
{% when _ %}
|
||||
<pre class="context">{{line}}</pre>
|
||||
{% endmatch %}
|
||||
{% endfor %}
|
||||
</code>
|
||||
</details>
|
||||
<div class="actions">
|
||||
{% if i == 0 %}
|
||||
<button disabled>CURRENT</button>
|
||||
{% else %}
|
||||
<form method="POST" action="./history/revert">
|
||||
<input type="hidden" name="oid" value="{{commit.oid}}" />
|
||||
<button type="submit">Revert to this revision</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<style scoped>
|
||||
.stack-name {
|
||||
color: var(--text-accent);
|
||||
}
|
||||
|
||||
summary {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.versions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1ch;
|
||||
|
||||
& article {
|
||||
border: 1px solid var(--table-border-color);
|
||||
background-color: var(--table-bg);
|
||||
border-radius: 3px;
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5ch;
|
||||
|
||||
& p {
|
||||
font-size: 9pt;
|
||||
margin: 0;
|
||||
|
||||
& .commit_id::before {
|
||||
content: " - ";
|
||||
}
|
||||
}
|
||||
|
||||
& code {
|
||||
font-size: 10pt;
|
||||
background-color: var(--bg-raised);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 5px;
|
||||
padding: 4px 6px;
|
||||
margin-top: 0.5ch;
|
||||
}
|
||||
|
||||
& pre {
|
||||
&.context {
|
||||
color: var(--button-color);
|
||||
}
|
||||
|
||||
&.add {
|
||||
color: var(--success-text);
|
||||
}
|
||||
|
||||
&.del {
|
||||
color: var(--danger-text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
& button {
|
||||
padding: 2px 6px;
|
||||
font-size: 10pt;
|
||||
}
|
||||
}
|
||||
|
||||
header {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script type="module">
|
||||
const actions = document.getElementById("page-actions");
|
||||
const toggle_text = ["Collapse all", "Expand all"];
|
||||
|
||||
/** @type {HTMLDetailsElement[]} */
|
||||
const versions = [...document.querySelectorAll(".versions details")];
|
||||
|
||||
let toggle = document.createElement("button");
|
||||
toggle.type = "button";
|
||||
toggle.appendChild(document.createTextNode(toggle_text[0]));
|
||||
toggle.addEventListener("click", () => {
|
||||
const index = toggle_text.indexOf(toggle.textContent);
|
||||
versions.forEach(version => { version.open = index == 1 });
|
||||
toggle.textContent = toggle_text[(index + 1) % 2];
|
||||
});
|
||||
actions.appendChild(toggle);
|
||||
</script>
|
||||
{% endblock %}
|
Reference in a new issue