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",
|
"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"
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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)]
|
||||||
|
|
|
@ -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();
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
|
|
|
@ -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)"
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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("./"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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();
|
||||||
|
|
|
@ -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())
|
||||||
|
|
|
@ -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);
|
|
@ -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 %}
|
|
@ -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);
|
||||||
|
|
Reference in a new issue