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",
"memchr",
"mime",
"multer",
"percent-encoding",
"pin-project-lite",
"rustversion",
@ -626,6 +627,15 @@ version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "equivalent"
version = "1.0.1"
@ -1247,6 +1257,24 @@ dependencies = [
"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]]
name = "new_debug_unreachable"
version = "1.0.4"
@ -1994,6 +2022,12 @@ dependencies = [
"url",
]
[[package]]
name = "spin"
version = "0.9.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
[[package]]
name = "st-map"
version = "0.2.0"

View file

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

View file

@ -1,7 +1,7 @@
use anyhow::Result;
use git2::DiffLine;
use git2::{DiffLine, TreeWalkMode, TreeWalkResult};
use serde::Serialize;
use std::path::Path;
use std::{collections::HashMap, path::Path};
use time::{
format_description::well_known::{Iso8601, Rfc2822},
OffsetDateTime,
@ -23,11 +23,11 @@ pub struct CommitInfo {
pub message: String,
pub author: String,
pub date: String,
pub changes: Vec<LineChange>,
pub changes: HashMap<String, Vec<LineChange>>,
}
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 mut revwalk = repository.revwalk()?;
@ -35,41 +35,77 @@ impl ThreadSafeRepository {
revwalk.set_sorting(git2::Sort::TIME | git2::Sort::REVERSE)?;
let mut history = Vec::new();
let mut last_blob = None;
let mut last_blobs = HashMap::new();
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()) {
let mut changed = vec![];
// 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;
}
// Get changes
let current = repository.find_blob(entry.id())?;
let old = last_blob.and_then(|id| repository.find_blob(id).ok());
// Get changes for each file
let mut file_changes = HashMap::new();
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![];
repository.diff_blobs(
old.as_ref(),
None,
Some(&current),
None,
None,
None,
None,
None,
Some(&mut |_, _, line| {
changes.push(line.into());
true
}),
)?;
let mut changes = vec![];
repository.diff_blobs(
old.as_ref(),
None,
Some(&current),
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());
// Write new blob id to compare against
last_blobs.insert(file_path.clone(), id);
// Add changes to file changes list
file_changes.insert(file_path, changes);
}
history.push(CommitInfo {
oid: commit.id().to_string(),
@ -79,7 +115,7 @@ impl ThreadSafeRepository {
.unwrap_or_else(|_| OffsetDateTime::now_utc())
.format(&Iso8601::DEFAULT)
.unwrap_or_default(),
changes,
changes: file_changes,
});
}
@ -109,17 +145,24 @@ impl From<DiffLine<'_>> for LineChange {
}
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 diff(&self) -> HashMap<String, Vec<String>> {
let mut files = HashMap::new();
for (file, changes) in &self.changes {
let mut ordered = changes.clone();
ordered.sort_by(|a, b| {
let line_a = a.old_line.or(a.new_line).unwrap_or_default();
let line_b = b.old_line.or(b.new_line).unwrap_or_default();
line_a.cmp(&line_b)
});
files.insert(
file.clone(),
ordered
.iter()
.map(|change| format!("{} {}", change.op, change.content))
.collect(),
);
}
files
}
pub fn date_human(&self) -> String {

View file

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

View file

@ -45,8 +45,8 @@ pub async fn response_interceptor<B>(
let accept = request
.headers()
.get(&ACCEPT)
.map(|header| parse_accept(header))
.unwrap_or_else(|| ResponseType::HTML);
.map(parse_accept)
.unwrap_or_else(|| ResponseType::Html);
let mut response = next.run(request).await;
@ -57,7 +57,7 @@ pub async fn response_interceptor<B>(
}) = response.extensions_mut().remove::<ErrorInfo>()
{
match accept {
ResponseType::JSON => {
ResponseType::Json => {
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>() {
match accept {
ResponseType::JSON => {
ResponseType::Json => {
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('<') {
Some((name, email)) => Ok((
name.trim().to_string(),
email.trim_end_matches(">").trim().to_string(),
email.trim_end_matches('>').trim().to_string(),
)),
None => Err(anyhow!(
"invalid git author format (email must be specified)"

View file

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

View file

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

View file

@ -1,12 +1,8 @@
use std::collections::HashMap;
use axum::{
extract::{Path, State},
extract::{Multipart, Path, State},
http::StatusCode,
response::Redirect,
Form,
};
use serde::Deserialize;
use crate::{
http::error::{AppError, Result},
@ -14,24 +10,31 @@ use crate::{
AppState,
};
#[derive(Deserialize)]
pub(super) struct EditStackForm {
files: HashMap<String, String>,
commit_message: String,
}
pub(super) async fn edit_stack(
Path(stack_name): Path<String>,
State(state): State<AppState>,
Form(form): Form<EditStackForm>,
mut multipart: Multipart,
) -> Result<Redirect> {
let commit_message = if form.commit_message.trim().is_empty() {
format!("Update {}", stack_name)
} else {
form.commit_message
};
let mut commit_message = format!("Update {}", stack_name);
let mut files = FileList::new();
while let Ok(Some(field)) = multipart.next_field().await {
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 {
status: StatusCode::BAD_REQUEST,
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?;
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 info = parse_arion_compose(&compose_file)?;
let git_compose_path = compose::path(&stack_name);
let commits = state.repository.get_history(&git_compose_path)?;
let commits = state.repository.get_history(&stack_name)?;
let mut newest_first = commits.clone();
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 labels = cont.labels.clone().unwrap_or_default();
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 {
private editor: AceAjax.Editor;
private tabEl: HTMLDivElement;
private files: Record<string, AceAjax.IEditSession>;
private sessions: Record<string, AceAjax.IEditSession>;
private root: ShadowRoot;
private addTabButton: HTMLDivElement;
constructor() {
super();
this.files = {};
this.sessions = {};
this.root = this.attachShadow({ mode: "open" });
}
connectedCallback() {
this.addTabButton = $el("div", { className: "tab" }, "+");
this.addTabButton.addEventListener("click", () => this.#createNewTab());
this.tabEl = $el("div", { className: "tab-container" }, this.addTabButton);
this.root.append(this.tabEl);
@ -66,10 +67,10 @@ class Editor extends HTMLElement {
*/
addFile(name: string, content: string) {
// Add to session list
this.files[name] = ace.createEditSession(content);
this.sessions[name] = ace.createEditSession(content);
// 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
const tab = this.#addTab(name);
@ -77,13 +78,17 @@ class Editor extends HTMLElement {
}
setCurrent(name: string) {
this.editor.setSession(this.files[name]);
this.editor.setSession(this.sessions[name]);
this.tabEl.querySelectorAll<HTMLDivElement>(".tab").forEach(el => {
if (el.dataset.name === name) el.classList.add("active");
else el.classList.remove("active");
});
}
files(): [String, String][] {
return Object.entries(this.sessions).map(([name, session]) => [name, session.getValue()]);
}
/**
* Create a new tab
* @param name Tab name
@ -98,6 +103,11 @@ class Editor extends HTMLElement {
this.tabEl.insertBefore(tab, this.addTabButton);
return tab;
}
#createNewTab() {
this.addFile(`untitled${Object.keys(this.sessions).filter(s => s.startsWith("untitled")).length + 1}`, "");
}
}
customElements.define("file-editor", Editor);

View file

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

View file

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