Add embeds to the Textarea

This commit is contained in:
Sebastian Martinez
2024-12-13 12:26:51 +01:00
parent 9fd2d489ae
commit ca4d9d13fd
20 changed files with 1156 additions and 278 deletions
Generated
+685 -86
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -31,6 +31,7 @@ tauri-plugin-window-state = { version = "2.0.1" }
thiserror = { version = "1.0.64" }
ts-rs = { version = "10.0.0", features = ["serde-json-impl", "no-serde-warnings"] }
tokio = { version = "1.40.0", features = ["time"] }
tauri-plugin-dialog = { version = "2.2.0" }
[features]
# by default Tauri runs in production mode
@@ -16,6 +16,7 @@
"shell:allow-open",
"clipboard-manager:default",
"clipboard-manager:allow-write-text",
"log:default"
"log:default",
"dialog:default"
]
}
+59 -6
View File
@@ -1,3 +1,7 @@
use std::path::PathBuf;
use anyhow::{Context, Result};
use radicle::cob;
use radicle::git;
use radicle::identity;
@@ -5,6 +9,8 @@ use radicle_types as types;
use radicle_types::error::Error;
use radicle_types::traits::cobs::Cobs;
use radicle_types::traits::thread::Thread;
use tauri_plugin_clipboard_manager::ClipboardExt;
use tauri_plugin_dialog::DialogExt;
use crate::AppState;
@@ -13,22 +19,69 @@ pub mod issue;
pub mod patch;
#[tauri::command]
pub async fn get_file_by_oid(
pub async fn get_embed(
ctx: tauri::State<'_, AppState>,
rid: identity::RepoId,
oid: git::Oid,
) -> Result<String, Error> {
) -> Result<Vec<u8>, Error> {
ctx.get_embed(rid, oid)
}
#[tauri::command]
pub async fn save_embed(
pub async fn save_embed_by_path(
ctx: tauri::State<'_, AppState>,
rid: identity::RepoId,
name: &str,
bytes: &[u8],
path: PathBuf,
) -> Result<git::Oid, Error> {
ctx.save_embed(rid, name, bytes)
ctx.save_embed_by_path(rid, path)
}
#[tauri::command]
pub async fn save_embed_by_clipboard(
app_handle: tauri::AppHandle,
ctx: tauri::State<'_, AppState>,
rid: identity::RepoId,
name: String,
) -> Result<git::Oid, Error> {
let content = app_handle
.clipboard()
.read_image()
.map(|i| i.rgba().to_vec())
.context("Not able to read the image from the clipboard")?;
ctx.save_embed_by_bytes(rid, name, content)
}
#[tauri::command]
pub async fn save_embed_by_bytes(
ctx: tauri::State<'_, AppState>,
rid: identity::RepoId,
name: String,
bytes: Vec<u8>,
) -> Result<git::Oid, Error> {
ctx.save_embed_by_bytes(rid, name, bytes)
}
#[tauri::command]
pub async fn save_embed_to_disk(
app_handle: tauri::AppHandle,
ctx: tauri::State<'_, AppState>,
rid: identity::RepoId,
oid: git::Oid,
name: String,
) -> Result<(), Error> {
let path = app_handle
.dialog()
.file()
.set_file_name(name)
.blocking_save_file()
.context("no path defined")?;
let path = path
.into_path()
.context("Not able to convert into PathBuf")?;
ctx.save_embed_to_disk(rid, oid, path)
}
#[tauri::command]
+8 -3
View File
@@ -13,7 +13,9 @@ use commands::{auth, cob, diff, profile, repo, thread};
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
#[cfg(debug_assertions)]
let builder = tauri::Builder::default().plugin(tauri_plugin_log::Builder::new().build());
let builder = tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_log::Builder::new().build());
#[cfg(not(debug_assertions))]
let builder = tauri::Builder::default();
@@ -74,9 +76,12 @@ pub fn run() {
repo::diff_stats,
repo::list_commits,
diff::get_diff,
cob::get_file_by_oid,
cob::get_embed,
cob::save_embed_to_disk,
cob::activity_by_id,
cob::save_embed,
cob::save_embed_by_path,
cob::save_embed_by_clipboard,
cob::save_embed_by_bytes,
cob::issue::list_issues,
cob::issue::issue_by_id,
cob::issue::comment_threads_by_issue_id,
+8
View File
@@ -9,6 +9,14 @@ pub enum Error {
#[error(transparent)]
Profile(#[from] radicle::profile::Error),
/// Anyhow error.
#[error(transparent)]
Anyhow(#[from] anyhow::Error),
/// Io error.
#[error(transparent)]
Io(#[from] std::io::Error),
/// Crypto error.
#[error(transparent)]
Crypto(#[from] radicle::crypto::ssh::keystore::Error),
+1
View File
@@ -202,6 +202,7 @@ pub trait Repo: Profile {
#[allow(clippy::unwrap_used)]
mod test {
use std::str::FromStr;
use std::vec;
use radicle::crypto::test::signer::MockSigner;
use radicle::{git, test};
+36 -7
View File
@@ -1,4 +1,5 @@
use base64::{engine::general_purpose::STANDARD, Engine as _};
use std::fs;
use localtime::LocalTime;
use radicle::cob;
@@ -14,23 +15,51 @@ use crate::error::Error;
use crate::traits::Profile;
pub trait Thread: Profile {
fn get_embed(&self, rid: identity::RepoId, oid: git::Oid) -> Result<String, Error> {
fn get_embed(&self, rid: identity::RepoId, oid: git::Oid) -> Result<Vec<u8>, Error> {
let profile = self.profile();
let repo = profile.storage.repository(rid)?;
let blob = repo.blob(oid)?;
Ok::<_, Error>(STANDARD.encode(blob.content()))
Ok::<_, Error>(blob.content().to_vec())
}
fn save_embed(
fn save_embed_to_disk(
&self,
rid: identity::RepoId,
name: &str,
bytes: &[u8],
oid: git::Oid,
path: std::path::PathBuf,
) -> Result<(), Error> {
let profile = self.profile();
let repo = profile.storage.repository(rid)?;
let blob = repo.blob(oid)?;
fs::write(path, blob.content())?;
Ok::<_, Error>(())
}
fn save_embed_by_path(
&self,
rid: identity::RepoId,
path: std::path::PathBuf,
) -> Result<git::Oid, Error> {
let profile = self.profile();
let repo = profile.storage.repository(rid)?;
let embed = radicle::cob::Embed::<git::Oid>::store(name, bytes, &repo.backend)?;
let bytes = fs::read(path.clone())?;
let name = path.file_name().and_then(|s| s.to_str()).unwrap_or("embed");
let embed = radicle::cob::Embed::<git::Oid>::store(name, &bytes, &repo.backend)?;
Ok(embed.oid())
}
fn save_embed_by_bytes(
&self,
rid: identity::RepoId,
name: String,
bytes: Vec<u8>,
) -> Result<git::Oid, Error> {
let profile = self.profile();
let repo = profile.storage.repository(rid)?;
let embed = radicle::cob::Embed::<git::Oid>::store(&name, &bytes, &repo.backend)?;
Ok(embed.oid())
}
+9 -6
View File
@@ -1,4 +1,5 @@
use std::ops::Deref;
use std::path::PathBuf;
use std::sync::Arc;
use axum::extract::State;
@@ -67,8 +68,11 @@ pub fn router(ctx: Context) -> Router {
.route("/list_patches", post(patches_handler))
.route("/patch_by_id", post(patch_handler))
.route("/revisions_by_patch", post(revision_handler))
.route("/get_file_by_oid", post(get_embeds_handler))
.route("/save_embed", post(save_embed_handler))
.route("/get_embed", post(get_embeds_handler))
.route("/save_embed_by_path", post(save_embed_handler))
.route("/save_embed_by_clipboard", post(save_embed_handler))
.route("/save_embed_by_bytes", post(save_embed_handler))
.route("/save_embed_to_disk", post(save_embed_handler))
.layer(
CorsLayer::new()
.allow_origin(cors::Any)
@@ -255,15 +259,14 @@ async fn get_embeds_handler(
#[derive(Serialize, Deserialize)]
struct CreateEmbedBody {
pub rid: identity::RepoId,
pub name: String,
pub content: Vec<u8>,
pub path: PathBuf,
}
async fn save_embed_handler(
State(ctx): State<Context>,
Json(CreateEmbedBody { rid, name, content }): Json<CreateEmbedBody>,
Json(CreateEmbedBody { rid, path }): Json<CreateEmbedBody>,
) -> impl IntoResponse {
let embed = ctx.save_embed(rid, &name, &content)?;
let embed = ctx.save_embed_by_path(rid, path)?;
Ok::<_, Error>(Json(embed))
}
+13 -5
View File
@@ -11,7 +11,8 @@
"license": "MIT",
"dependencies": {
"@tauri-apps/api": "^2.1.1",
"@tauri-apps/plugin-clipboard-manager": "^2.0.0",
"@tauri-apps/plugin-clipboard-manager": "^2.2.0",
"@tauri-apps/plugin-dialog": "^2.2.0",
"@tauri-apps/plugin-log": "^2.0.0",
"@tauri-apps/plugin-shell": "^2.0.0",
"@tauri-apps/plugin-window-state": "^2.0.0"
@@ -1341,10 +1342,17 @@
}
},
"node_modules/@tauri-apps/plugin-clipboard-manager": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-clipboard-manager/-/plugin-clipboard-manager-2.0.0.tgz",
"integrity": "sha512-V1sXmbjnwfXt/r48RJMwfUmDMSaP/8/YbH4CLNxt+/sf1eHlIP8PRFdFDQwLN0cNQKu2rqQVbG/Wc/Ps6cDUhw==",
"license": "MIT OR Apache-2.0",
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-clipboard-manager/-/plugin-clipboard-manager-2.2.0.tgz",
"integrity": "sha512-sIBrW/HioKq2vqomwwcU/Y8ygAv3DlS32yKPBX5XijCc0IyQKiDxYpGqmvE9DC5Y0lNJ/G53dfS961B31wjJ1g==",
"dependencies": {
"@tauri-apps/api": "^2.0.0"
}
},
"node_modules/@tauri-apps/plugin-dialog": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.2.0.tgz",
"integrity": "sha512-6bLkYK68zyK31418AK5fNccCdVuRnNpbxquCl8IqgFByOgWFivbiIlvb79wpSXi0O+8k8RCSsIpOquebusRVSg==",
"dependencies": {
"@tauri-apps/api": "^2.0.0"
}
+2 -1
View File
@@ -25,7 +25,8 @@
"license": "MIT",
"dependencies": {
"@tauri-apps/api": "^2.1.1",
"@tauri-apps/plugin-clipboard-manager": "^2.0.0",
"@tauri-apps/plugin-clipboard-manager": "^2.2.0",
"@tauri-apps/plugin-dialog": "^2.2.0",
"@tauri-apps/plugin-log": "^2.0.0",
"@tauri-apps/plugin-shell": "^2.0.0",
"@tauri-apps/plugin-window-state": "^2.0.0"
+6
View File
@@ -64,6 +64,12 @@ body {
font-weight: var(--font-weight-regular);
}
@media (max-width: 1600px) {
.global-hide-on-small-desktop-down {
display: none !important;
}
}
:root {
--elevation-low: 0 0 48px 0 #000000ee;
}
+3
View File
@@ -6,6 +6,7 @@
variant: "primary" | "secondary" | "ghost" | "float" | "danger";
hoverable?: boolean;
onclick?: () => void;
stylePosition?: string;
stylePadding?: string;
styleHeight?: string;
styleMinHeight?: string;
@@ -27,6 +28,7 @@
stylePadding,
styleHeight,
styleMinHeight,
stylePosition,
styleWidth,
styleCursor = "default",
styleGap = "0.5rem",
@@ -222,6 +224,7 @@
<div class="p3-2"></div>
<div
class="p3-3"
style:position={stylePosition}
style:padding={stylePadding}
style:gap={styleGap}
style:overflow={styleOverflow}
+164 -23
View File
@@ -1,9 +1,15 @@
<script lang="ts">
import type { UnlistenFn } from "@tauri-apps/api/event";
import type { ComponentProps } from "svelte";
import type { Embed } from "@bindings/cob/thread/Embed";
import * as utils from "@app/lib/utils";
import type { Embed } from "@bindings/cob/thread/Embed";
import { invoke } from "@app/lib/invoke";
import { listen } from "@tauri-apps/api/event";
import { onDestroy, onMount } from "svelte";
import { open } from "@tauri-apps/plugin-dialog";
import Button from "./Button.svelte";
import Icon from "./Icon.svelte";
import Markdown from "./Markdown.svelte";
@@ -11,6 +17,7 @@
import OutlineButton from "./OutlineButton.svelte";
interface Props {
styleMinHeight?: string;
rid: string;
placeholder?: string;
submitCaption?: string;
@@ -21,6 +28,7 @@
submitInProgress?: boolean;
disallowEmptyBody?: boolean;
isValid?: () => boolean;
preview?: boolean;
stylePadding?: string;
borderVariant?: ComponentProps<typeof Textarea>["borderVariant"];
submit: (opts: {
@@ -32,6 +40,8 @@
/* eslint-disable prefer-const */
let {
preview = $bindable(false),
styleMinHeight,
rid,
placeholder = "Leave your comment",
submitCaption = "Comment",
@@ -49,12 +59,124 @@
}: Props = $props();
/* eslint-enable prefer-const */
let preview: boolean = $state(false);
let selectionStart = $state(0);
let selectionEnd = $state(0);
let inputFiles: FileList | undefined = $state(undefined);
let selectionStart = $state(body.length);
let selectionEnd = $state(body.length);
let draggingOver = $state(false);
let dragEnterUnlistenFn: UnlistenFn | undefined = undefined;
let dragLeaveUnlistenFn: UnlistenFn | undefined = undefined;
let dragDropUnlistenFn: UnlistenFn | undefined = undefined;
const inputId = `input-label-${crypto.randomUUID()}`;
function updateBodyAndSelection(input: string[], pre: string, after: string) {
const allEmbeds = input.join("");
body = pre.concat(allEmbeds, after);
selectionStart = pre.length + allEmbeds.length;
selectionEnd = pre.length + allEmbeds.length;
}
function splitBody() {
return [body.substring(0, selectionStart), body.substring(selectionStart)];
}
onMount(async () => {
if (window.__TAURI_INTERNALS__) {
dragEnterUnlistenFn = await listen("tauri://drag-enter", () => {
draggingOver = true;
});
dragLeaveUnlistenFn = await listen("tauri://drag-leave", () => {
draggingOver = false;
});
dragDropUnlistenFn = await listen<{
paths: string[];
position: { x: number; y: number };
}>("tauri://drag-drop", async event => {
draggingOver = false;
const [preBody, afterBody] = splitBody();
return Promise.all(
event.payload.paths.map(async path => {
const name = path.split("/").at(-1);
const uploadLabel = `[Uploading ${name}...]()\n`;
body = preBody.concat(uploadLabel, afterBody);
const oid = await invoke<string>("save_embed_by_path", {
rid,
path,
}).catch(console.error);
return `[${name}](${oid})\n`;
}),
).then(texts => updateBodyAndSelection(texts, preBody, afterBody));
});
}
});
onDestroy(() => {
if (dragEnterUnlistenFn) dragEnterUnlistenFn();
if (dragLeaveUnlistenFn) dragLeaveUnlistenFn();
if (dragDropUnlistenFn) dragDropUnlistenFn();
});
async function attachEmbedsByPaths(paths: string[]) {
const [preBody, afterBody] = splitBody();
return Promise.all(
paths.map(async path => {
const name = path.split("/").at(-1);
const uploadLabel = `[Uploading ${name}...]()\n`;
body = preBody.concat(uploadLabel, afterBody);
const oid = await invoke<string>("save_embed_by_path", {
rid,
path,
}).catch(console.error);
return `[${name}](${oid})\n`;
}),
).then(texts => updateBodyAndSelection(texts, preBody, afterBody));
}
async function handlePaste(e: ClipboardEvent) {
if (e.clipboardData?.files && e.clipboardData.files.length > 0) {
e.preventDefault();
const [preBody, afterBody] = splitBody();
// We read the buffer on the backend, if it's a image buffer.
if (e.clipboardData.items.length === 1) {
const file = e.clipboardData.files[0];
const uploadLabel = `[Uploading...]()\n`;
body = preBody.concat(uploadLabel, afterBody);
const oid = await invoke<string>("save_embed_by_clipboard", {
name: file.name,
rid,
}).catch(console.error);
body = preBody.concat(`[${file.name}](${oid})\n`, afterBody);
} else {
return Promise.all(
Array.from(e.clipboardData.files).map(async file => {
const arrayBuffer = await file.arrayBuffer();
const bytes = new Uint8Array(arrayBuffer);
const uploadLabel = `[Uploading ${file.name}...]()\n`;
body = preBody.concat(uploadLabel, afterBody);
const oid = await invoke<string>("save_embed_by_bytes", {
rid,
name: file.name,
bytes,
}).catch(console.error);
return `[${file.name}](${oid})\n`;
}),
).then(texts => updateBodyAndSelection(texts, preBody, afterBody));
}
} else {
// In case that the clipboard data isn't an array of files,
// we want to make use of the default behavior and insert the clipboard content.
}
}
function selectFiles() {
void open({ multiple: true }).then(paths => {
if (paths) {
void attachEmbedsByPaths(paths);
}
});
}
function submitFn() {
void submit({ comment: body, embeds })
@@ -87,16 +209,19 @@
.buttons {
display: flex;
margin-left: auto;
gap: 1rem;
gap: 0.5rem;
}
.caption {
font-size: var(--font-size-small);
color: var(--color-fill-gray);
display: flex;
align-items: center;
flex-wrap: wrap;
align-items: flex-start;
gap: 0.25rem;
}
.preview {
width: 100%;
font-size: var(--font-size-small);
min-height: 109px;
padding: 0.75rem;
@@ -111,17 +236,14 @@
<Markdown {rid} breaks content={body} />
</div>
{:else}
<input
multiple
bind:files={inputFiles}
style:display="none"
type="file"
id={inputId} />
<Textarea
{draggingOver}
{borderVariant}
{stylePadding}
{styleMinHeight}
bind:selectionEnd
bind:selectionStart
onpaste={handlePaste}
{focus}
submit={() => submit({ comment: body, embeds })}
bind:value={body}
@@ -135,20 +257,37 @@
preview = false;
close();
}}>
<Icon name="cross" />Discard
<Icon name="cross" />
<span class="global-hide-on-small-desktop-down">Discard</span>
</OutlineButton>
{#if !preview}
<div class="caption">
<Icon name="markdown" />
Markdown is supported. Press {utils.modifierKey()}↵ to submit.
Drag and drop files to add them.
<div style="display: flex; align-items: center; gap: 0.25rem;">
<Icon name="markdown" />
Markdown is supported.
</div>
<span class="global-hide-on-small-desktop-down">
<br />
</span>
<span class="global-hide-on-small-desktop-down">
Press {utils.modifierKey()}↵ to submit.
</span>
</div>
{/if}
<div class="buttons">
<OutlineButton variant="ghost" onclick={selectFiles}>
<Icon name="attachment" />
<span class="global-hide-on-small-desktop-down">Attach</span>
</OutlineButton>
<OutlineButton
variant="ghost"
disabled={body.trim() === ""}
onclick={() => (preview = !preview)}>
<Icon name={preview ? "pen" : "eye"} />{preview ? "Edit" : "Preview"}
<Icon name={preview ? "pen" : "eye"} />
<span class="global-hide-on-small-desktop-down">
{preview ? "Edit" : "Preview"}
</span>
</OutlineButton>
<Button
variant="ghost"
@@ -157,11 +296,13 @@
(disallowEmptyBody && body.trim() === "")}
onclick={submitFn}>
<Icon name="checkmark" />
{#if submitInProgress}
Saving…
{:else}
{submitCaption}
{/if}
<span class="global-hide-on-small-desktop-down">
{#if submitInProgress}
Saving…
{:else}
{submitCaption}
{/if}
</span>
</Button>
</div>
</div>
+14
View File
@@ -6,6 +6,7 @@
onclick?: () => void;
disabled?: boolean;
name:
| "attachment"
| "arrow-left"
| "arrow-right"
| "checkmark"
@@ -108,6 +109,19 @@
<path d="M10 3H9.00003V13H10V3Z" />
<path d="M13 6H12V7H13V6Z" />
<path d="M14 7H13V8H14V7Z" />
{:else if name === "attachment"}
<path d="M4 4H12V5H4V4Z" />
<path d="M4 11H11V12H4V11Z" />
<path d="M13 6H14V8H13V6Z" />
<path d="M2 6H3V10H2V6Z" />
<path d="M12 5L13 5V6H12V5Z" />
<path d="M3 5L4 5V6L3 6L3 5Z" />
<path d="M12 8H13V9H12V8Z" />
<path d="M3 10H4L4 11H3L3 10Z" />
<path d="M5 9L12 9V10H5V9Z" />
<path d="M4 8H5V9L4 9V8Z" />
<path d="M5 6H11V7H5V6Z" />
<path d="M4 7H5L5 8H4L4 7Z" />
{:else if name === "checkmark"}
<path d="M7 11V12H6V11H7Z" />
<path d="M8 10V11L7 11L7 10H8Z" />
+63 -16
View File
@@ -41,6 +41,9 @@
}
$effect(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
content;
void tick().then(() => {
for (const e of container.querySelectorAll("a")) {
try {
@@ -59,6 +62,66 @@
) {
e.classList.add("no-underline");
}
// Iterate over all links, and try to add a base64 preview beneath it.
const href = e.getAttribute("href");
// If the markdown link is an oid embed
if (href && isCommit(href)) {
e.style.display = "block";
e.onclick = event => {
event.preventDefault();
invoke("save_embed_to_disk", {
rid,
oid: href,
name: e.innerText,
}).catch(console.error);
};
void invoke<Uint8Array>("get_embed", {
rid,
oid: href,
})
.then(byteArray => {
const buffer = Buffer.from(byteArray);
const blob = new Blob([buffer]);
const url = URL.createObjectURL(blob);
const ext = e.innerHTML.split(".").at(-1);
// Embed an img element below the link
if (ext?.match(/(gif|jpe?g|tiff?|png|webp|bmp)/)) {
const element = document.createElement("img");
element.setAttribute("src", url);
e.insertAdjacentElement("afterend", element);
// Embed an iframe to display pdf correctly element below the link
} else if (ext?.match(/(pdf)/)) {
const element = document.createElement("embed");
element.setAttribute("src", url);
element.type = "application/pdf";
element.style.overflow = "scroll";
element.style.height = "40rem";
element.style.overscrollBehavior = "contain";
e.insertAdjacentElement("afterend", element);
} else if (ext?.match(/(mp4|mov)/)) {
const element = document.createElement("video");
const node = document.createElement("source");
node.src = url;
element.controls = true;
node.type = `video/mp4`;
element.style.width = "100%";
element.appendChild(node);
e.insertAdjacentElement("afterend", element);
} else if (ext?.match(/(mp3)/)) {
const element = document.createElement("audio");
element.src = url;
element.controls = true;
e.insertAdjacentElement("afterend", element);
} else {
console.warn(
`Not able to provide a preview for ${e.innerHTML}`,
);
}
})
.catch(console.error);
}
}
// Replace standard HTML checkboxes with our custom radicle-icon-small element
@@ -75,22 +138,6 @@
i.remove();
}
// Iterate over all images, and replace the source with a canonicalized URL
// pointing at the repos /raw endpoint.
for (const i of container.querySelectorAll("img")) {
const imagePath = i.getAttribute("src");
// If the image is an oid embed
if (imagePath && isCommit(imagePath)) {
void invoke<string>("get_file_by_oid", {
rid,
oid: imagePath,
}).then(base64Content =>
i.setAttribute("src", `data:image/jpeg;base64,${base64Content}`),
);
}
}
// Replaces code blocks in the background with highlighted code.
const prefix = "language-";
const nodes = Array.from(document.body.querySelectorAll("pre code"));
+47 -10
View File
@@ -1,15 +1,18 @@
<script lang="ts">
import type { FormEventHandler } from "svelte/elements";
import type {
ClipboardEventHandler,
FormEventHandler,
} from "svelte/elements";
import type { ComponentProps } from "svelte";
import { tick } from "svelte";
import { onMount, tick } from "svelte";
import * as utils from "@app/lib/utils";
import Border from "./Border.svelte";
interface Props {
draggingOver?: boolean;
borderVariant?: ComponentProps<typeof Border>["variant"];
onpaste?: ClipboardEventHandler<HTMLTextAreaElement>;
focus?: boolean;
oninput?: FormEventHandler<HTMLTextAreaElement>;
onkeypress?: FormEventHandler<HTMLTextAreaElement>;
@@ -25,8 +28,10 @@
/* eslint-disable prefer-const */
let {
draggingOver,
borderVariant = "float",
focus = false,
onpaste,
oninput,
onkeypress,
placeholder = undefined,
@@ -44,6 +49,28 @@
let textareaElement: HTMLTextAreaElement | undefined = $state(undefined);
let focussed = $state(false);
onMount(() => {
if (textareaElement) {
// The selectionchange event listener doesn't modify the selection on Enter.
textareaElement.addEventListener("keydown", (event: KeyboardEvent) => {
if (event.key === "Enter") {
selectionStart += 1;
selectionEnd += 1;
}
});
textareaElement.addEventListener("selectionchange", (event: Event) => {
if (
event.target &&
"selectionStart" in event.target &&
"selectionEnd" in event.target
) {
selectionStart = event.target.selectionStart as number;
selectionEnd = event.target.selectionEnd as number;
}
});
}
});
// We either auto-grow the textarea, or allow the user to resize it. These
// options are mutually exclusive because a user resized textarea would
// automatically shrink upon text input otherwise.
@@ -67,12 +94,6 @@
}
});
$effect.pre(() => {
if (textareaElement) {
({ selectionStart, selectionEnd } = textareaElement);
}
});
$effect(() => {
void tick().then(() => {
if (textareaElement && focus) {
@@ -123,10 +144,22 @@
textarea::placeholder {
color: var(--color-foreground-dim);
}
.dragover {
position: absolute;
opacity: 0.5;
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
background-color: var(--color-background-float);
}
</style>
<Border
variant={focussed ? "secondary" : borderVariant}
stylePosition="relative"
styleWidth="100%"
{styleMinHeight}>
<textarea
@@ -142,10 +175,14 @@
? "scroll"
: undefined}
{placeholder}
{onpaste}
{oninput}
{onkeypress}
onfocus={() => (focussed = true)}
onblur={() => (focussed = false)}
onkeydown={handleKeydown}>
</textarea>
{#if draggingOver}
<div class="txt-small dragover">Drop files to add them as embeds.</div>
{/if}
</Border>
-38
View File
@@ -1,38 +0,0 @@
async function parseGitOid(bytes: Uint8Array): Promise<string> {
// Create the header
const header = new TextEncoder().encode(`blob ${bytes.length}\0`);
// Concatenate the header and the original file content
const combined = new Uint8Array(header.length + bytes.length);
combined.set(header);
combined.set(bytes, header.length);
// Compute the SHA-1 hash
const hashBuffer = await crypto.subtle.digest("SHA-1", combined);
const hashArray = Array.from(new Uint8Array(hashBuffer));
// Convert the hash to a hexadecimal string
return hashArray.map(b => b.toString(16).padStart(2, "0")).join("");
}
function base64String(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (event: ProgressEvent<FileReader>) => {
if (event.target?.result && typeof event.target.result === "string") {
resolve(event.target.result);
} else {
reject(new Error("Failed to generate base64 string"));
}
};
reader.readAsDataURL(file);
});
}
export async function embed(file: File) {
const bytes = new Uint8Array(await file.arrayBuffer());
const oid = await parseGitOid(bytes);
const content = await base64String(file);
return { oid, name: file.name, content };
}
+31 -72
View File
@@ -4,6 +4,7 @@
import type { Issue } from "@bindings/cob/issue/Issue";
import type { RepoInfo } from "@bindings/repo/RepoInfo";
import type { IssueStatus } from "./router";
import type { Embed } from "@bindings/cob/thread/Embed";
import { invoke } from "@app/lib/invoke";
@@ -14,18 +15,14 @@
import AssigneeInput from "@app/components/AssigneeInput.svelte";
import Border from "@app/components/Border.svelte";
import Button from "@app/components/Button.svelte";
import Icon from "@app/components/Icon.svelte";
import InlineTitle from "@app/components/InlineTitle.svelte";
import IssueSecondColumn from "@app/components/IssueSecondColumn.svelte";
import LabelInput from "@app/components/LabelInput.svelte";
import Markdown from "@app/components/Markdown.svelte";
import OutlineButton from "@app/components/OutlineButton.svelte";
import Sidebar from "@app/components/Sidebar.svelte";
import TextInput from "@app/components/TextInput.svelte";
import Textarea from "@app/components/Textarea.svelte";
import Layout from "./Layout.svelte";
import ExtendedTextarea from "@app/components/ExtendedTextarea.svelte";
interface Props {
repo: RepoInfo;
@@ -36,17 +33,17 @@
const { repo, issues, config, status }: Props = $props();
let description: string = $state("");
let preview: boolean = $state(false);
let title: string = $state("");
const embeds: { name: string; content: string }[] = [];
let assignees: Author[] = $state([]);
let labels: string[] = $state([]);
async function createIssue() {
const response: Issue = await invoke("create_issue", {
async function createIssue(
description: string,
embeds: Embed[],
): Promise<Issue> {
return invoke("create_issue", {
rid: repo.rid,
new: {
title,
@@ -57,12 +54,6 @@
},
opts: { announce: $nodeRunning && $announce },
});
void router.push({
resource: "repo.issue",
rid: repo.rid,
issue: response.id,
status,
});
}
</script>
@@ -79,12 +70,6 @@
padding: 0 1rem 1rem 1rem;
height: calc(100% - 14rem);
}
.body {
background-color: var(--color-background-float);
padding: 1rem;
min-height: calc(100% + 2px);
clip-path: var(--2px-corner-fill);
}
.metadata-divider {
width: 2px;
background-color: var(--color-fill-ghost);
@@ -148,55 +133,29 @@
</Border>
</div>
{#if preview}
<div class="txt-small body">
{#if description.trim() === ""}
<span class="txt-missing" style:line-height="1.625rem">
No description.
</span>
{:else}
<Markdown rid={repo.rid} content={description} breaks />
{/if}
</div>
{:else}
<Textarea
borderVariant="ghost"
placeholder="Description"
bind:value={description}
size="fixed-height"
submit={createIssue}
styleMinHeight="100%" />
{/if}
<div
class="global-flex"
style:justify-content="space-between"
style:padding-bottom="1.5rem"
style:margin-top="1.5rem">
<OutlineButton
variant="ghost"
onclick={() => {
window.history.back();
}}>
<Icon name="cross" />Discard
</OutlineButton>
<div class="global-flex">
<div class="global-flex txt-small txt-missing">
<Icon name="markdown" />
Markdown is supported.
</div>
<OutlineButton
variant="ghost"
disabled={title.length === 0}
onclick={() => (preview = !preview)}>
<Icon name={preview ? "pen" : "eye"} />{preview ? "Edit" : "Preview"}
</OutlineButton>
<Button
variant="ghost"
disabled={title.length === 0}
onclick={createIssue}>
<Icon name="checkmark" />Save
</Button>
</div>
</div>
<ExtendedTextarea
submitCaption="Save"
close={() => window.history.back()}
submit={async ({ comment, embeds }) => {
try {
const response = await createIssue(
comment,
Array.from(embeds.values()),
);
void router.push({
resource: "repo.issue",
rid: repo.rid,
issue: response.id,
status,
});
} catch {
console.error("Not able to create issue.");
}
}}
styleMinHeight="100%"
rid={repo.rid}
bind:preview
borderVariant="ghost"
placeholder="Description" />
</div>
</Layout>
+4 -4
View File
@@ -41,7 +41,7 @@ test("creation of top level comments", async ({ page }) => {
.fill(
"It's important for us that the comment creation flow works as expected.",
);
await page.getByRole("button", { name: "Save" }).click();
await page.getByRole("button", { name: "icon-checkmark" }).click();
await expect(
page.getByText("Make sure that comment creation is working").last(),
).toBeVisible();
@@ -60,7 +60,7 @@ test("creation of top level comments", async ({ page }) => {
await page
.getByPlaceholder("Leave a comment")
.fill("A top level comment by playwright");
await page.getByRole("button", { name: "icon-checkmark Comment" }).click();
await page.getByRole("button", { name: "icon-checkmark" }).click();
await expect(
page.getByText("A top level comment by playwright"),
).toBeVisible();
@@ -71,7 +71,7 @@ test("creation of top level comments", async ({ page }) => {
.fill(
"A top level comment by playwright created by replying to the issue body",
);
await page.getByRole("button", { name: "icon-checkmark Comment" }).click();
await page.getByRole("button", { name: "icon-checkmark" }).click();
await expect(
page.getByText(
"A top level comment by playwright created by replying to the issue body",
@@ -82,7 +82,7 @@ test("creation of top level comments", async ({ page }) => {
await page
.getByPlaceholder("Reply to comment")
.fill("A reply comment by playwright replying to the first comment");
await page.getByRole("button", { name: "icon-checkmark Reply" }).click();
await page.getByRole("button", { name: "icon-checkmark" }).click();
await expect(
page.getByText(
"A reply comment by playwright replying to the first comment",