multi file drifting

This commit is contained in:
Hamcha 2023-12-03 11:36:45 +01:00
parent 23d0185a12
commit 87f198ae36
14 changed files with 195 additions and 98 deletions

34
Cargo.lock generated
View file

@ -254,6 +254,7 @@ dependencies = [
"matchit", "matchit",
"memchr", "memchr",
"mime", "mime",
"multer",
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"rustversion", "rustversion",
@ -626,6 +627,15 @@ version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a26ae43d7bcc3b814de94796a5e736d4029efb0ee900c12e2d54c993ad1a1e07" checksum = "a26ae43d7bcc3b814de94796a5e736d4029efb0ee900c12e2d54c993ad1a1e07"
[[package]]
name = "encoding_rs"
version = "0.8.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7268b386296a025e474d5140678f75d6de9493ae55a5d709eeb9dd08149945e1"
dependencies = [
"cfg-if",
]
[[package]] [[package]]
name = "equivalent" name = "equivalent"
version = "1.0.1" version = "1.0.1"
@ -1247,6 +1257,24 @@ dependencies = [
"windows-sys", "windows-sys",
] ]
[[package]]
name = "multer"
version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "01acbdc23469fd8fe07ab135923371d5f5a422fbf9c522158677c8eb15bc51c2"
dependencies = [
"bytes",
"encoding_rs",
"futures-util",
"http",
"httparse",
"log",
"memchr",
"mime",
"spin",
"version_check",
]
[[package]] [[package]]
name = "new_debug_unreachable" name = "new_debug_unreachable"
version = "1.0.4" version = "1.0.4"
@ -1994,6 +2022,12 @@ dependencies = [
"url", "url",
] ]
[[package]]
name = "spin"
version = "0.9.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
[[package]] [[package]]
name = "st-map" name = "st-map"
version = "0.2.0" version = "0.2.0"

View file

@ -7,7 +7,7 @@ edition = "2021"
anyhow = { version = "1", features = ["backtrace"] } anyhow = { version = "1", features = ["backtrace"] }
askama = { version = "0.12", features = ["with-axum"] } askama = { version = "0.12", features = ["with-axum"] }
askama_axum = "0.3" askama_axum = "0.3"
axum = "0.6" axum = { version = "0.6", features = ["multipart"] }
bollard = { version = "0.15", features = ["time"] } bollard = { version = "0.15", features = ["time"] }
clap = { version = "4", features = ["env", "derive"] } clap = { version = "4", features = ["env", "derive"] }
dotenvy = "0.15" dotenvy = "0.15"

View file

@ -1,7 +1,7 @@
use anyhow::Result; use anyhow::Result;
use git2::DiffLine; use git2::{DiffLine, TreeWalkMode, TreeWalkResult};
use serde::Serialize; use serde::Serialize;
use std::path::Path; use std::{collections::HashMap, path::Path};
use time::{ use time::{
format_description::well_known::{Iso8601, Rfc2822}, format_description::well_known::{Iso8601, Rfc2822},
OffsetDateTime, OffsetDateTime,
@ -23,11 +23,11 @@ pub struct CommitInfo {
pub message: String, pub message: String,
pub author: String, pub author: String,
pub date: String, pub date: String,
pub changes: Vec<LineChange>, pub changes: HashMap<String, Vec<LineChange>>,
} }
impl ThreadSafeRepository { impl ThreadSafeRepository {
pub fn get_history(&self, path: &Path) -> Result<Vec<CommitInfo>> { pub fn get_history(&self, path: &str) -> Result<Vec<CommitInfo>> {
let repository = self.repository()?; let repository = self.repository()?;
let mut revwalk = repository.revwalk()?; let mut revwalk = repository.revwalk()?;
@ -35,22 +35,54 @@ impl ThreadSafeRepository {
revwalk.set_sorting(git2::Sort::TIME | git2::Sort::REVERSE)?; revwalk.set_sorting(git2::Sort::TIME | git2::Sort::REVERSE)?;
let mut history = Vec::new(); let mut history = Vec::new();
let mut last_blob = None; let mut last_blobs = HashMap::new();
for oid in revwalk { for oid in revwalk {
// Get commit // Get commit
let oid = oid?; let oid = oid?;
let commit = repository.find_commit(oid)?; let commit = repository.find_commit(oid)?;
let tree = commit.tree()?; let tree = commit.tree()?;
// Check if file changed in this commit let mut changed = vec![];
let entry = tree.get_path(path)?;
if last_blob == Some(entry.id()) { // Check if any file we care about was modified
tree.walk(TreeWalkMode::PreOrder, |val, entry| {
// We're at root, only traverse the path we care about
if val.is_empty() {
if entry.name().unwrap_or_default() == path {
TreeWalkResult::Ok
} else {
TreeWalkResult::Skip
}
} else {
// Track files, skip directories
if entry.kind() == Some(git2::ObjectType::Blob) {
let file_path = entry.name().unwrap_or_default().to_string();
// Check if blob exists
match last_blobs.get(&file_path) {
Some(blob_id) => {
if blob_id != &entry.id() {
changed.push((file_path, entry.id()))
}
}
None => changed.push((file_path, entry.id())),
}
}
TreeWalkResult::Skip
}
})?;
if changed.is_empty() {
continue; continue;
} }
// Get changes // Get changes for each file
let current = repository.find_blob(entry.id())?; let mut file_changes = HashMap::new();
let old = last_blob.and_then(|id| repository.find_blob(id).ok()); for (file_path, id) in changed {
let current = repository.find_blob(id)?;
let old = last_blobs
.get(&file_path)
.and_then(|id| repository.find_blob(*id).ok());
let mut changes = vec![]; let mut changes = vec![];
repository.diff_blobs( repository.diff_blobs(
@ -69,7 +101,11 @@ impl ThreadSafeRepository {
)?; )?;
// Write new blob id to compare against // Write new blob id to compare against
last_blob = Some(entry.id()); last_blobs.insert(file_path.clone(), id);
// Add changes to file changes list
file_changes.insert(file_path, changes);
}
history.push(CommitInfo { history.push(CommitInfo {
oid: commit.id().to_string(), oid: commit.id().to_string(),
@ -79,7 +115,7 @@ impl ThreadSafeRepository {
.unwrap_or_else(|_| OffsetDateTime::now_utc()) .unwrap_or_else(|_| OffsetDateTime::now_utc())
.format(&Iso8601::DEFAULT) .format(&Iso8601::DEFAULT)
.unwrap_or_default(), .unwrap_or_default(),
changes, changes: file_changes,
}); });
} }
@ -109,17 +145,24 @@ impl From<DiffLine<'_>> for LineChange {
} }
impl CommitInfo { impl CommitInfo {
pub fn diff(&self) -> Vec<String> { pub fn diff(&self) -> HashMap<String, Vec<String>> {
let mut ordered = self.changes.clone(); let mut files = HashMap::new();
for (file, changes) in &self.changes {
let mut ordered = changes.clone();
ordered.sort_by(|a, b| { ordered.sort_by(|a, b| {
let line_a = a.old_line.or_else(|| a.new_line).unwrap_or_default(); let line_a = a.old_line.or(a.new_line).unwrap_or_default();
let line_b = b.old_line.or_else(|| b.new_line).unwrap_or_default(); let line_b = b.old_line.or(b.new_line).unwrap_or_default();
line_a.cmp(&line_b) line_a.cmp(&line_b)
}); });
files.insert(
file.clone(),
ordered ordered
.iter() .iter()
.map(|change| format!("{} {}", change.op, change.content)) .map(|change| format!("{} {}", change.op, change.content))
.collect() .collect(),
);
}
files
} }
pub fn date_human(&self) -> String { pub fn date_human(&self) -> String {

View file

@ -29,17 +29,17 @@ where
/// Supported content types /// Supported content types
pub(super) enum ResponseType { pub(super) enum ResponseType {
HTML, Html,
JSON, Json,
} }
/// Parses the Accept header and returns content type to return /// Parses the Accept header and returns content type to return
pub(super) fn parse_accept(accept: &HeaderValue) -> ResponseType { pub(super) fn parse_accept(accept: &HeaderValue) -> ResponseType {
let bytes = accept.as_bytes(); let bytes = accept.as_bytes();
if bytes.starts_with(b"application/json") { if bytes.starts_with(b"application/json") {
return ResponseType::JSON; return ResponseType::Json;
} }
ResponseType::HTML ResponseType::Html
} }
#[cfg(test)] #[cfg(test)]

View file

@ -45,8 +45,8 @@ pub async fn response_interceptor<B>(
let accept = request let accept = request
.headers() .headers()
.get(&ACCEPT) .get(&ACCEPT)
.map(|header| parse_accept(header)) .map(parse_accept)
.unwrap_or_else(|| ResponseType::HTML); .unwrap_or_else(|| ResponseType::Html);
let mut response = next.run(request).await; let mut response = next.run(request).await;
@ -57,7 +57,7 @@ pub async fn response_interceptor<B>(
}) = response.extensions_mut().remove::<ErrorInfo>() }) = response.extensions_mut().remove::<ErrorInfo>()
{ {
match accept { match accept {
ResponseType::JSON => { ResponseType::Json => {
return (status, Json(json!({"code": code, "message": message}))).into_response(); return (status, Json(json!({"code": code, "message": message}))).into_response();
} }
_ => { _ => {
@ -75,7 +75,7 @@ pub async fn response_interceptor<B>(
if let Some(Response { html, json }) = response.extensions_mut().remove::<Response>() { if let Some(Response { html, json }) = response.extensions_mut().remove::<Response>() {
match accept { match accept {
ResponseType::JSON => { ResponseType::Json => {
return Json(json).into_response(); return Json(json).into_response();
} }
_ => { _ => {

View file

@ -105,7 +105,7 @@ fn parse_author(git_author: &str) -> Result<(String, String)> {
match git_author.split_once('<') { match git_author.split_once('<') {
Some((name, email)) => Ok(( Some((name, email)) => Ok((
name.trim().to_string(), name.trim().to_string(),
email.trim_end_matches(">").trim().to_string(), email.trim_end_matches('>').trim().to_string(),
)), )),
None => Err(anyhow!( None => Err(anyhow!(
"invalid git author format (email must be specified)" "invalid git author format (email must be specified)"

View file

@ -78,7 +78,7 @@ impl From<ContainerInspectResponse> for ContainerInfo {
state: value state: value
.state .state
.and_then(|s| s.status) .and_then(|s| s.status)
.unwrap_or_else(|| bollard::service::ContainerStateStatusEnum::EMPTY) .unwrap_or(bollard::service::ContainerStateStatusEnum::EMPTY)
.to_string(), .to_string(),
image: config.image.unwrap_or_default(), image: config.image.unwrap_or_default(),
image_id: value.image.unwrap_or_default(), image_id: value.image.unwrap_or_default(),
@ -100,32 +100,32 @@ pub async fn get_info(docker: &Docker, name: &str) -> Result<ContainerInfo> {
pub async fn start(docker: &Docker, name: &str) -> Result<()> { pub async fn start(docker: &Docker, name: &str) -> Result<()> {
Ok(docker Ok(docker
.start_container(&name, None::<StartContainerOptions<String>>) .start_container(name, None::<StartContainerOptions<String>>)
.await?) .await?)
} }
pub async fn restart(docker: &Docker, name: &str) -> Result<()> { pub async fn restart(docker: &Docker, name: &str) -> Result<()> {
Ok(docker Ok(docker
.restart_container(&name, Some(RestartContainerOptions { t: 30 })) .restart_container(name, Some(RestartContainerOptions { t: 30 }))
.await?) .await?)
} }
pub async fn stop(docker: &Docker, name: &str) -> Result<()> { pub async fn stop(docker: &Docker, name: &str) -> Result<()> {
Ok(docker Ok(docker
.stop_container(&name, Some(StopContainerOptions { t: 30 })) .stop_container(name, Some(StopContainerOptions { t: 30 }))
.await?) .await?)
} }
pub async fn kill(docker: &Docker, name: &str) -> Result<()> { pub async fn kill(docker: &Docker, name: &str) -> Result<()> {
Ok(docker Ok(docker
.kill_container(&name, None::<KillContainerOptions<String>>) .kill_container(name, None::<KillContainerOptions<String>>)
.await?) .await?)
} }
pub async fn remove(docker: &Docker, name: &str) -> Result<()> { pub async fn remove(docker: &Docker, name: &str) -> Result<()> {
Ok(docker Ok(docker
.remove_container( .remove_container(
&name, name,
Some(RemoveContainerOptions { Some(RemoveContainerOptions {
v: false, v: false,
force: true, force: true,

View file

@ -25,7 +25,7 @@ pub fn parse_arion_compose(file: &str) -> Result<StackComposeInfo> {
.filter_map(|(key, _)| { .filter_map(|(key, _)| {
key.strip_prefix(SERVICES_PREFIX) key.strip_prefix(SERVICES_PREFIX)
.and_then(|key| key.split_once('\0')) .and_then(|key| key.split_once('\0'))
.and_then(|k| Some(k.0.to_string())) .map(|k| k.0.to_string())
}) })
.collect::<HashSet<_>>() .collect::<HashSet<_>>()
.into_iter() .into_iter()

View file

@ -1,12 +1,8 @@
use std::collections::HashMap;
use axum::{ use axum::{
extract::{Path, State}, extract::{Multipart, Path, State},
http::StatusCode, http::StatusCode,
response::Redirect, response::Redirect,
Form,
}; };
use serde::Deserialize;
use crate::{ use crate::{
http::error::{AppError, Result}, http::error::{AppError, Result},
@ -14,24 +10,31 @@ use crate::{
AppState, AppState,
}; };
#[derive(Deserialize)]
pub(super) struct EditStackForm {
files: HashMap<String, String>,
commit_message: String,
}
pub(super) async fn edit_stack( pub(super) async fn edit_stack(
Path(stack_name): Path<String>, Path(stack_name): Path<String>,
State(state): State<AppState>, State(state): State<AppState>,
Form(form): Form<EditStackForm>, mut multipart: Multipart,
) -> Result<Redirect> { ) -> Result<Redirect> {
let commit_message = if form.commit_message.trim().is_empty() { let mut commit_message = format!("Update {}", stack_name);
format!("Update {}", stack_name) let mut files = FileList::new();
} else { while let Ok(Some(field)) = multipart.next_field().await {
form.commit_message let name = field.name().unwrap_or_default().to_string();
}; let data = field.text().await.map_err(|e| AppError::Client {
status: StatusCode::BAD_REQUEST,
code: "invalid-data",
message: e.to_string(),
})?;
if form.files.is_empty() { if name.starts_with("files/") {
// Clean up source (like line endings) in each file
let source = data.replace("\r\n", "\n");
files.insert(name.strip_prefix("files/").unwrap().to_string(), source);
} else if name == "commit_message" && !data.is_empty() {
commit_message = data;
}
}
if files.is_empty() {
return Err(AppError::Client { return Err(AppError::Client {
status: StatusCode::BAD_REQUEST, status: StatusCode::BAD_REQUEST,
code: "invalid-files", code: "invalid-files",
@ -39,14 +42,7 @@ pub(super) async fn edit_stack(
}); });
} }
// Cleanup source (like line endings) in each file
let mut files = FileList::new();
for (name, source) in form.files {
files.insert(name, source.replace("\r\n", "\n"));
}
edit_stack_int(state, &stack_name, &files, &commit_message).await?; edit_stack_int(state, &stack_name, &files, &commit_message).await?;
Ok(Redirect::to("./")) Ok(Redirect::to("./"))
} }

View file

@ -39,8 +39,7 @@ pub(super) async fn list(
let compose_file = compose::get(&state.stack_dir, &stack_name).await?; let compose_file = compose::get(&state.stack_dir, &stack_name).await?;
let info = parse_arion_compose(&compose_file)?; let info = parse_arion_compose(&compose_file)?;
let git_compose_path = compose::path(&stack_name); let commits = state.repository.get_history(&stack_name)?;
let commits = state.repository.get_history(&git_compose_path)?;
let mut newest_first = commits.clone(); let mut newest_first = commits.clone();
newest_first.reverse(); newest_first.reverse();

View file

@ -14,7 +14,7 @@ impl StackInfo {
} }
} }
fn get_service(containers: &Vec<ContainerInfo>, stack_name: &str, service: &str) -> ServiceInfo { fn get_service(containers: &[ContainerInfo], stack_name: &str, service: &str) -> ServiceInfo {
let container = containers.iter().find(|cont| { let container = containers.iter().find(|cont| {
let labels = cont.labels.clone().unwrap_or_default(); let labels = cont.labels.clone().unwrap_or_default();
labels.get("com.docker.compose.project") == Some(&stack_name.to_string()) labels.get("com.docker.compose.project") == Some(&stack_name.to_string())

View file

@ -4,18 +4,19 @@ import { $el } from "/static/vendor/domutil/domutil.js";
class Editor extends HTMLElement { class Editor extends HTMLElement {
private editor: AceAjax.Editor; private editor: AceAjax.Editor;
private tabEl: HTMLDivElement; private tabEl: HTMLDivElement;
private files: Record<string, AceAjax.IEditSession>; private sessions: Record<string, AceAjax.IEditSession>;
private root: ShadowRoot; private root: ShadowRoot;
private addTabButton: HTMLDivElement; private addTabButton: HTMLDivElement;
constructor() { constructor() {
super(); super();
this.files = {}; this.sessions = {};
this.root = this.attachShadow({ mode: "open" }); this.root = this.attachShadow({ mode: "open" });
} }
connectedCallback() { connectedCallback() {
this.addTabButton = $el("div", { className: "tab" }, "+"); this.addTabButton = $el("div", { className: "tab" }, "+");
this.addTabButton.addEventListener("click", () => this.#createNewTab());
this.tabEl = $el("div", { className: "tab-container" }, this.addTabButton); this.tabEl = $el("div", { className: "tab-container" }, this.addTabButton);
this.root.append(this.tabEl); this.root.append(this.tabEl);
@ -66,10 +67,10 @@ class Editor extends HTMLElement {
*/ */
addFile(name: string, content: string) { addFile(name: string, content: string) {
// Add to session list // Add to session list
this.files[name] = ace.createEditSession(content); this.sessions[name] = ace.createEditSession(content);
// TODO replace this with auto-detection // TODO replace this with auto-detection
this.files[name].setMode("ace/mode/nix"); this.sessions[name].setMode("ace/mode/nix");
// Create tab and set as active // Create tab and set as active
const tab = this.#addTab(name); const tab = this.#addTab(name);
@ -77,13 +78,17 @@ class Editor extends HTMLElement {
} }
setCurrent(name: string) { setCurrent(name: string) {
this.editor.setSession(this.files[name]); this.editor.setSession(this.sessions[name]);
this.tabEl.querySelectorAll<HTMLDivElement>(".tab").forEach(el => { this.tabEl.querySelectorAll<HTMLDivElement>(".tab").forEach(el => {
if (el.dataset.name === name) el.classList.add("active"); if (el.dataset.name === name) el.classList.add("active");
else el.classList.remove("active"); else el.classList.remove("active");
}); });
} }
files(): [String, String][] {
return Object.entries(this.sessions).map(([name, session]) => [name, session.getValue()]);
}
/** /**
* Create a new tab * Create a new tab
* @param name Tab name * @param name Tab name
@ -98,6 +103,11 @@ class Editor extends HTMLElement {
this.tabEl.insertBefore(tab, this.addTabButton); this.tabEl.insertBefore(tab, this.addTabButton);
return tab; return tab;
} }
#createNewTab() {
this.addFile(`untitled${Object.keys(this.sessions).filter(s => s.startsWith("untitled")).length + 1}`, "");
}
} }
customElements.define("file-editor", Editor); customElements.define("file-editor", Editor);

View file

@ -61,13 +61,13 @@
Editor Editor
<form action="./history"><button title="View past versions">History</button></form> <form action="./history"><button title="View past versions">History</button></form>
</h2> </h2>
<form method="POST" action="./edit" id="editor-form"> <form method="POST" enctype="multipart/form-data" action="./edit" id="editor-form">
<div class="error"></div> <div class="error"></div>
{% for (filename, content) in files %} {% for (filename, content) in files %}
<script type="stackfile" data-name="{{filename}}">{{content|safe}}</script> <script type="stackfile" data-name="{{filename}}">{{content|safe}}</script>
<noscript> <noscript>
<h3>{{filename}}</h3> <h3>{{filename}}</h3>
<textarea name="file[{{filename}}]" class="nojs-editor">{{content|safe}}</textarea> <textarea name="files/{{filename}}" class="nojs-editor">{{content|safe}}</textarea>
</noscript> </noscript>
{% endfor %} {% endfor %}
<file-editor></file-editor> <file-editor></file-editor>
@ -114,14 +114,19 @@
<script type="module"> <script type="module">
const editor = document.querySelector("file-editor"); const editor = document.querySelector("file-editor");
const form = document.getElementById("editor-form");
document.querySelectorAll("script[type=stackfile]").forEach((script) => { document.querySelectorAll("script[type=stackfile]").forEach((script) => {
editor.addFile(script.dataset.name, script.innerText); editor.addFile(script.dataset.name, script.innerText);
}); });
editor.setCurrent("arion-compose.nix"); editor.setCurrent("arion-compose.nix");
/* Enforce check pre-submit */ /* Enforce check pre-submit */
//const form = document.getElementById("editor-form");
//add_check(form, editor); //add_check(form, editor);
form.addEventListener("formdata", (ev) => {
editor.files().forEach(([filename, content]) => {
ev.formData.set(`files/${filename}`, content);
});
});
</script> </script>
{% endblock %} {% endblock %}

View file

@ -12,18 +12,23 @@
<section class="versions"> <section class="versions">
{% for (i, commit) in commits.iter().enumerate() %} {% for (i, commit) in commits.iter().enumerate() %}
<article> <article>
<p> <header>
<time datetime="{{commit.date}}">{{commit.date_human()}}</time> <time datetime="{{commit.date}}">{{commit.date_human()}}</time>
<span class="commit_id"> <span class="commit_id">
{{commit.oid[..7]}} {{commit.oid[..7]}}
</span> </span>
</p> <span class="commit_author">
({{commit.author}})
</span>
</header>
{% for (file, lines) in commit.diff() %}
<p class="commit-message">{{commit.message}}</p>
<details open> <details open>
<summary title="{{commit.message}} ({{commit.author}})"> <summary title="{{commit.message}} ({{commit.author}})">
{{commit.message}} {{file}}
</summary> </summary>
<code> <code>
{% for line in commit.diff() %} {% for line in lines %}
{% match line.chars().next() %} {% match line.chars().next() %}
{% when Some with ('+') %} {% when Some with ('+') %}
<pre class="add">{{line}}</pre> <pre class="add">{{line}}</pre>
@ -35,6 +40,7 @@
{% endfor %} {% endfor %}
</code> </code>
</details> </details>
{% endfor %}
<div class="actions"> <div class="actions">
{% if i == 0 %} {% if i == 0 %}
<button disabled>CURRENT</button> <button disabled>CURRENT</button>
@ -73,7 +79,7 @@
flex-direction: column; flex-direction: column;
gap: 0.5ch; gap: 0.5ch;
& p { & header {
font-size: 9pt; font-size: 9pt;
margin: 0; margin: 0;
@ -82,6 +88,10 @@
} }
} }
& p {
margin: 0;
}
& code { & code {
font-size: 10pt; font-size: 10pt;
background-color: var(--bg-raised); background-color: var(--bg-raised);