multi file drifting
This commit is contained in:
parent
23d0185a12
commit
87f198ae36
14 changed files with 195 additions and 98 deletions
34
Cargo.lock
generated
34
Cargo.lock
generated
|
@ -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"
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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,22 +35,54 @@ 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(
|
||||
|
@ -69,7 +101,11 @@ impl ThreadSafeRepository {
|
|||
)?;
|
||||
|
||||
// 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 {
|
||||
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();
|
||||
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_else(|| a.new_line).unwrap_or_default();
|
||||
let line_b = b.old_line.or_else(|| b.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(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()
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
files
|
||||
}
|
||||
|
||||
pub fn date_human(&self) -> String {
|
||||
|
|
|
@ -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)]
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
_ => {
|
||||
|
|
|
@ -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)"
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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("./"))
|
||||
}
|
||||
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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())
|
||||
|
|
|
@ -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);
|
|
@ -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 %}
|
|
@ -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);
|
||||
|
|
Reference in a new issue