Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
171 changes: 171 additions & 0 deletions src/Loom.Web/Components/Pages/FeatureWorkspace.razor
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,13 @@
@inject IFeatureService Features
@inject IArtifactRepository ArtifactRepo
@inject Loom.Application.Artifacts.IArtifactService Artifacts
@inject Loom.Application.Artifacts.IArtifactBlobStore BlobStore
@inject IWorkflowEngine Engine
@inject IWorkflowRepository Workflows
@inject IServiceScopeFactory ScopeFactory
@inject Loom.Web.Services.CurrentUserAccessor CurrentUser
@inject NavigationManager Nav
@inject IJSRuntime JS

<PageTitle>@(_view?.Title ?? "Loading…") · Loom</PageTitle>

Expand Down Expand Up @@ -402,6 +404,38 @@ else
</h2>
@if (_attachingArtifact)
{
<div id="attach-dropzone" class="attach-dropzone @(_attachDropActive ? "is-active" : null)"
@ondragenter:preventDefault @ondragenter="@(_ => _attachDropActive = true)"
@ondragover:preventDefault
@ondragleave="@(_ => _attachDropActive = false)"
@ondrop="@(_ => _attachDropActive = false)">
@* Native input absorbs both clicks and drops. Positioned to fill the dropzone via CSS so the visible content shows through but the input still wins for click + drop routing. *@
<InputFile OnChange="OnInputFileChangedAsync" class="attach-dropzone-input" title="Drop, paste, or click to attach a file" />
<div class="attach-dropzone-content">
@if (_attachUploadBusy)
{
<p class="muted" style="margin:0;">Uploading…</p>
}
else if (_attachUploadedRef is { } r)
{
<p style="margin:0;">
<strong>Uploaded:</strong> @r.Filename
<span class="muted"> · @r.ContentType · @FormatBytes(r.SizeBytes)</span>
<button class="btn-link" type="button" @onclick="ClearUploadedRef" style="margin-left:.5rem; position:relative; z-index:2;">clear</button>
</p>
}
else
{
<p class="muted" style="margin:0;">
Drop a file, paste an image, or click to choose.
</p>
}
@if (!string.IsNullOrEmpty(_attachUploadError))
{
<p class="error" style="margin:.25rem 0 0 0;">@_attachUploadError</p>
}
</div>
</div>
<div class="form-row" style="display:grid; grid-template-columns: 1fr 1fr; gap:.5rem; margin-bottom:.5rem;">
<label>
Title
Expand Down Expand Up @@ -536,6 +570,20 @@ else
private string? _attachError;
private AttachArtifactDraft _attachDraft = new();

// Drop / paste upload state. _attachUploadedRef carries metadata
// back to the attach form so the Attach button can wire the
// resulting blob URI into the canonical pointer.
private bool _attachDropActive;
private bool _attachUploadBusy;
private string? _attachUploadError;
private UploadedRef? _attachUploadedRef;
private DotNetObjectReference<FeatureWorkspace>? _selfRef;
private bool _pasteListenerAttached;

// Hard caps so a misbehaving file doesn't OOM the server. Tune
// alongside ProjectArtifactsPanel and any future uploaders.
private const long MaxUploadBytes = 25 * 1024 * 1024;

private sealed class AttachArtifactDraft
{
public string Title { get; set; } = string.Empty;
Expand All @@ -545,6 +593,8 @@ else
public string ExternalId { get; set; } = string.Empty;
}

private sealed record UploadedRef(string Uri, string Filename, string ContentType, long? SizeBytes);

private bool _editingOutcomes;
private List<OutcomeDraft> _outcomeDrafts = [];
private bool _outcomesBusy;
Expand Down Expand Up @@ -599,6 +649,30 @@ else

protected override async Task OnAfterRenderAsync(bool firstRender)
{
// Wire the paste listener whenever the attach dropzone is on
// the page. Cheap to attach repeatedly — the JS module
// dedupes by element.
if (_attachingArtifact && !_pasteListenerAttached)
{
try
{
_selfRef ??= DotNetObjectReference.Create(this);
await JS.InvokeVoidAsync("import", "/js/attach-paste.js");
await JS.InvokeVoidAsync("loomAttachPaste", "attach-dropzone", _selfRef, "OnImagePastedAsync");
_pasteListenerAttached = true;
}
catch
{
// Paste support is best-effort; drop + file-picker still work.
}
}
if (!_attachingArtifact && _pasteListenerAttached)
{
try { await JS.InvokeVoidAsync("loomAttachPasteOff", "attach-dropzone"); }
catch { /* ignore */ }
_pasteListenerAttached = false;
}

if (firstRender && _hub is null)
{
_hub = new HubConnectionBuilder()
Expand Down Expand Up @@ -630,6 +704,12 @@ else
try { await _hub.SendAsync("LeaveNode", NodeId); } catch { /* ignore */ }
await _hub.DisposeAsync();
}
if (_pasteListenerAttached)
{
try { await JS.InvokeVoidAsync("loomAttachPasteOff", "attach-dropzone"); }
catch { /* ignore */ }
}
_selfRef?.Dispose();
}

// SignalR notifications can race the page's own LoadAsync (originated
Expand Down Expand Up @@ -888,7 +968,98 @@ else
{
_attachDraft = new AttachArtifactDraft();
_attachError = null;
_attachUploadError = null;
_attachUploadedRef = null;
}
else
{
_attachUploadedRef = null;
_attachUploadError = null;
}
}

/// <summary>
/// Forwarded by the JS paste listener (see /js/attach-paste.js).
/// Receives the pasted image as base64 because Blazor Server's JS
/// interop for raw byte arrays is awkward and base64 is small
/// enough at the 25 MB cap we apply.
/// </summary>
[JSInvokable]
public async Task OnImagePastedAsync(string filename, string contentType, string base64)
{
try
{
var bytes = Convert.FromBase64String(base64);
await UploadBundleAsync(filename, contentType, bytes);
}
catch (FormatException)
{
_attachUploadError = "Pasted content was not a valid image.";
}
StateHasChanged();
}

private async Task OnInputFileChangedAsync(InputFileChangeEventArgs e)
{
var f = e.File;
if (f is null) return;
await IngestBrowserFileAsync(f);
}

private async Task IngestBrowserFileAsync(IBrowserFile file)
{
if (file.Size > MaxUploadBytes)
{
_attachUploadError = $"File is too large ({FormatBytes(file.Size)}). Max is {FormatBytes(MaxUploadBytes)}.";
return;
}
await using var stream = file.OpenReadStream(MaxUploadBytes);
using var ms = new MemoryStream(capacity: (int)Math.Min(file.Size, MaxUploadBytes));
await stream.CopyToAsync(ms);
await UploadBundleAsync(file.Name, string.IsNullOrWhiteSpace(file.ContentType) ? "application/octet-stream" : file.ContentType, ms.ToArray());
}

private async Task UploadBundleAsync(string filename, string contentType, byte[] bytes)
{
_attachUploadBusy = true;
_attachUploadError = null;
try
{
var bundle = new Loom.Application.Artifacts.ArtifactBundle(
[new Loom.Application.Artifacts.ArtifactFile(filename, contentType, bytes)]);
var blobRef = await BlobStore.PutAsync(bundle);

_attachUploadedRef = new UploadedRef(blobRef.Uri, filename, contentType, blobRef.SizeBytes);
_attachDraft.Store = Loom.Domain.Artifacts.CanonicalStore.HubNative;
_attachDraft.Url = blobRef.Uri;
if (string.IsNullOrWhiteSpace(_attachDraft.Title))
{
_attachDraft.Title = Path.GetFileNameWithoutExtension(filename);
}
}
catch (Exception ex)
{
_attachUploadError = ex.Message;
}
finally
{
_attachUploadBusy = false;
}
}

private void ClearUploadedRef()
{
_attachUploadedRef = null;
_attachDraft.Url = string.Empty;
}

private static string FormatBytes(long? bytes)
{
if (bytes is null) return "?";
var b = bytes.Value;
if (b < 1024) return $"{b} B";
if (b < 1024 * 1024) return $"{b / 1024.0:0.#} KB";
return $"{b / (1024.0 * 1024.0):0.##} MB";
}

private async Task SaveAttachAsync()
Expand Down
41 changes: 30 additions & 11 deletions src/Loom.Web/Components/Pages/Tree/NodeRow.razor
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
@*
Recursive tree row. Renders the node's status, type, title, and phase,
then recurses into children. The row links into the Feature Workspace
page so the tree doubles as navigation.
Recursive tree row. Renders the node's status, type, title, and phase
on a single line, then recurses into children. The row links into the
Feature Workspace page so the tree doubles as navigation. Features
render in bold to distinguish them from the capabilities below.
*@

<div class="tree-row tree-depth-@Depth">
<div class="tree-row tree-depth-@Depth tree-type-@TypeClass(Row.Type)">
<StatusPulseBadge Signals="Row.Status" />
<span class="tree-type">@TypeAbbrev(Row.Type)</span>
<span class="tree-type-icon" title="@TypeLabel(Row.Type)" aria-label="@TypeLabel(Row.Type)">@TypeIcon(Row.Type)</span>
<a class="tree-title" href="@($"/n/{Row.NodeId.Value:D}")">@Row.Title</a>
<PhasePill Phase="Row.Phase" />
</div>
Expand All @@ -22,12 +23,30 @@
[Parameter, EditorRequired] public NodeTreeRow Row { get; set; } = default!;
[Parameter] public int Depth { get; set; }

private static string TypeAbbrev(NodeType t) => t switch
private static string TypeIcon(NodeType t) => t switch
{
NodeType.Initiative => "INIT",
NodeType.Feature => "FEAT",
NodeType.Capability => "CAP",
NodeType.Slice => "SLC",
_ => t.ToString().ToUpperInvariant()
NodeType.Initiative => "◆",
NodeType.Feature => "●",
NodeType.Capability => "○",
NodeType.Slice => "▸",
_ => "•"
};

private static string TypeLabel(NodeType t) => t switch
{
NodeType.Initiative => "Initiative",
NodeType.Feature => "Feature",
NodeType.Capability => "Capability",
NodeType.Slice => "Slice",
_ => t.ToString()
};

private static string TypeClass(NodeType t) => t switch
{
NodeType.Initiative => "initiative",
NodeType.Feature => "feature",
NodeType.Capability => "capability",
NodeType.Slice => "slice",
_ => "node"
};
}
6 changes: 5 additions & 1 deletion src/Loom.Web/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,11 @@
// ─── Blazor Server with interactive Server components ──────────────────
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents();
builder.Services.AddSignalR();
// 32 KB default kills the Blazor circuit when the artifact attach
// dropzone forwards a pasted image as base64. Cap matches the
// 25 MB upload limit enforced server-side, with headroom for base64
// expansion and the JSON envelope.
builder.Services.AddSignalR(o => o.MaximumReceiveMessageSize = 64 * 1024 * 1024);
builder.Services.AddControllers();

// ─── HTTP plumbing ─────────────────────────────────────────────────────
Expand Down
54 changes: 53 additions & 1 deletion src/Loom.Web/wwwroot/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -263,13 +263,28 @@ a { color: inherit; text-decoration: none; }
}
.tree-row {
display: grid;
grid-template-columns: 60px 1fr auto;
/* status-pulse · type-icon · title · phase-pill — single line so the
phase doesn't wrap below. The 1fr column absorbs slack. */
grid-template-columns: auto auto 1fr auto;
align-items: center;
gap: 14px;
padding: 10px 0 10px 16px;
border-bottom: 1px solid var(--border);
position: relative;
}
.tree-type-icon {
font-size: 14px;
color: var(--text-2);
line-height: 1;
width: 1.2em;
text-align: center;
cursor: help;
}
.tree-row.tree-type-feature .tree-title { font-weight: 700; }
.tree-row.tree-type-initiative .tree-title { font-weight: 700; letter-spacing: 0.01em; }
.tree-row.tree-type-feature .tree-type-icon { color: var(--accent); }
.tree-row.tree-type-initiative .tree-type-icon { color: var(--accent); }
.tree-row.tree-type-capability .tree-type-icon { color: var(--text-3); }
.tree-row:last-child { border-bottom: none; }
.tree-row::before {
content: '';
Expand Down Expand Up @@ -488,3 +503,40 @@ button:disabled,
opacity: 0.5;
cursor: not-allowed;
}

/* ─── Artifact attach: drop / paste / pick zone ──────────────────── */
.attach-dropzone {
position: relative;
border: 1px dashed var(--border-2);
border-radius: 6px;
padding: 1rem;
margin-bottom: .5rem;
background: var(--surface);
transition: border-color .12s ease, background .12s ease;
min-height: 60px;
}
.attach-dropzone.is-active {
border-color: var(--accent);
background: var(--surface-2);
}
/* Invisible native file input that fills the dropzone — accepts both
clicks (file picker) and drops (browser-native handling). */
.attach-dropzone-input {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
opacity: 0;
cursor: pointer;
z-index: 1;
}
.attach-dropzone-content {
position: relative;
z-index: 0;
}
/* Text inside the dropzone shouldn't intercept clicks meant for the
overlay input; only interactive elements (buttons, links) opt back
in via z-index + pointer-events. */
.attach-dropzone-content p { pointer-events: none; }
.attach-dropzone-content button,
.attach-dropzone-content a { pointer-events: auto; position: relative; z-index: 2; }
Loading
Loading