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
527 changes: 371 additions & 156 deletions app/application.ml

Large diffs are not rendered by default.

12 changes: 7 additions & 5 deletions app/journal_capture.ml
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,12 @@ let can_attach t =
t.phase <> Saving && List.length t.pending_attachments < attachment_limit
;;

let submission_source t =
match source_is_blank (source t), t.pending_attachments with
| true, first :: _ -> Journal_asset_import.staged_title first
| _ -> source t
;;

let add_attachment t staged =
let duplicate =
List.exists
Expand Down Expand Up @@ -227,11 +233,7 @@ let admit_save t ~mutation_id ~block_id ~sibling_order ~calendar_generation ~cre
else (
(* A blank capture only containing attachments names its block after the
first pick so the entry stays visible on the timeline. *)
let source =
match source_is_blank (source t), t.pending_attachments with
| true, first :: _ -> Journal_asset_import.staged_title first
| _ -> source t
in
let source = submission_source t in
let command : Journal_graph_projection.capture =
{ mutation_id
; block_id
Expand Down
3 changes: 3 additions & 0 deletions app/journal_capture.mli
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ val attachment_limit : int
(** Assets picked for this draft, awaiting attach-on-save. *)
val pending_attachments : t -> Journal_asset_import.staged list

(** Exact draft text, or the first attachment's friendly title for a blank draft. *)
val submission_source : t -> string

(** Whether another attachment may be added (not saving, under the limit). *)
val can_attach : t -> bool

Expand Down
51 changes: 50 additions & 1 deletion app/journal_detail.ml
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,55 @@ let session_id (t : t) = ID.Text_input.Session_id.of_int64 t.session_number
let composer_revision (t : t) = t.composer_revision
let reveal_id (t : t) = t.reveal_id
let child_capture (t : t) = t.child_capture

let map_child_capture (t : t) ~f =
match t.mode, t.child_capture with
| Saving_child, _ | _, None -> t
| (Reading | Failed _), Some capture ->
let next = f capture in
if next == capture
then t
else { t with child_capture = Some next; mode = Reading; pending = None }
;;

let matching_attachment_imports pending capture ~root_id ~child ~parent =
if matching_child pending ~root_id ~child ~parent
then
Option.bind capture (fun capture ->
match Journal_capture.pending_attachments capture with
| [] -> None
| items -> Some (Journal_model.id child, items))
else None
;;

let child_attachment_imports (t : t) ~child ~parent =
matching_attachment_imports t.pending t.child_capture ~root_id:t.root_id ~child ~parent
;;

let retained_attachment_imports draft ~child ~parent =
matching_attachment_imports
draft.draft_pending
(Some draft.draft_capture)
~root_id:draft.draft_parent
~child
~parent
;;

let retained_attachments draft = Journal_capture.pending_attachments draft.draft_capture
let retained_capture draft = draft.draft_capture
let retained_saving draft = draft.draft_mode = Saving_child

let map_retained_capture draft ~f =
if retained_saving draft
then draft
else (
let capture = f draft.draft_capture in
if capture == draft.draft_capture
then draft
else
{ draft with draft_capture = capture; draft_mode = Reading; draft_pending = None })
;;

let expanded (t : t) ~block_id = (branch t block_id).expanded
let continuation (t : t) ~parent_id = (branch t parent_id).continuation
let request_back _ = `Close
Expand Down Expand Up @@ -419,7 +468,7 @@ let admit_child
; creation_time
; parent_block_id = t.root_id
; expected_parent_revision = Journal_model.revision (root t)
; source = Journal_capture.source capture
; source = Journal_capture.submission_source capture
; task_state = Journal_capture.task_state capture
}
in
Expand Down
26 changes: 26 additions & 0 deletions app/journal_detail.mli
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,17 @@ val composer_revision : t -> int64
val reveal_id : t -> string option
val request_back : t -> [ `Close ]
val child_capture : t -> Journal_capture.t option

(** Update the owned draft only while editable; an admitted child remains frozen. *)
val map_child_capture : t -> f:(Journal_capture.t -> Journal_capture.t) -> t

(** Staged imports for this exact child completion, before retiring its draft. *)
val child_attachment_imports
: t
-> child:Journal_model.t
-> parent:Journal_model.t
-> (string * Journal_asset_import.staged list) option

val update_child_source : t -> string -> t
val apply_child_edit : t -> Journal_view.Event.Payload.text_edit -> t
val toggle_child_task : t -> t
Expand All @@ -74,6 +85,21 @@ val undo_delete : t -> staged_delete -> t
(** Only composer ownership is retained; reopening loads a fresh outline projection. *)
type retained_composer

val retained_attachments : retained_composer -> Journal_asset_import.staged list
val retained_capture : retained_composer -> Journal_capture.t
val retained_saving : retained_composer -> bool

val map_retained_capture
: retained_composer
-> f:(Journal_capture.t -> Journal_capture.t)
-> retained_composer

val retained_attachment_imports
: retained_composer
-> child:Journal_model.t
-> parent:Journal_model.t
-> (string * Journal_asset_import.staged list) option

val retain_composer : t -> retained_composer option
val restore_composer : t -> retained_composer -> t

Expand Down
77 changes: 47 additions & 30 deletions app/journal_header.ml
Original file line number Diff line number Diff line change
Expand Up @@ -53,15 +53,48 @@ let date_header ~title =
()
;;

let detail ~actions body =
let capture_control ~enabled ~on_capture =
V.buttons
~actions:
[ V.buttons_action
~label:"Capture"
~icon:"square.and.pencil"
~on_press:
(Ui.Event.Handler.create (fun payload ->
if enabled () then Ui.Event.Handler.Private.invoke on_capture payload))
()
]
()
|> V.semantics ~properties:(Ui.Semantics.create ~label:"Capture" ())
;;

let bottom_controls ~key ~controls body =
Ui.Native_widget.widget
chrome
~key:(Ui.Key.string key)
~props:(`Assoc [ "mode", `String "bottom-controls" ])
~on_event:(fun _ -> ())
~children:[ V.Body.Private.to_widget body; controls ]
()
|> V.Body.static
;;

let detail ~capture_enabled ~on_capture body =
let capture =
capture_control ~enabled:(fun () -> capture_enabled) ~on_capture
|> test_id "journal-detail-capture-open"
in
Ui.Native_widget.widget
chrome
~key:(Ui.Key.string "journal-detail-header")
~props:(`Assoc [ "mode", `String "detail"; "title", `String "Block" ])
~on_event:(fun _ -> ())
~children:[ V.Body.Private.to_widget body; V.buttons ~actions () ]
~children:[ V.Body.Private.to_widget body ]
()
|> V.Body.static
|> bottom_controls
~key:"journal-detail-bottom-controls"
~controls:(V.row ~spacing:16. [ V.spacer (); capture ])
;;

type account_action =
Expand Down Expand Up @@ -252,22 +285,12 @@ let view_impl
in
let capture =
(* buttons has no disabled state — guard the handler instead. *)
V.buttons
~actions:
[ V.buttons_action
~label:"Capture"
~icon:"square.and.pencil"
~on_press:
(Ui.Event.Handler.create (fun payload ->
if
match presentation_signal with
| None -> initial.capture_enabled
| Some signal -> (Signal.sample signal).capture_enabled
then Ui.Event.Handler.Private.invoke on_capture payload))
()
]
()
|> V.semantics ~properties:(Ui.Semantics.create ~label:"Capture" ())
capture_control
~enabled:(fun () ->
match presentation_signal with
| None -> initial.capture_enabled
| Some signal -> (Signal.sample signal).capture_enabled)
~on_capture
|> test_id "journal-capture-open"
in
let destinations =
Expand Down Expand Up @@ -373,18 +396,12 @@ let view_impl
then
(* The native inset reserves scrolling space; each buttons composite
supplies its own glass without system toolbar chrome. *)
Ui.Native_widget.widget
chrome
~key:(Ui.Key.string "journal-bottom-controls")
~props:(`Assoc [ "mode", `String "bottom-controls" ])
~on_event:(fun _ -> ())
~children:
[ V.Body.Private.to_widget body
; V.row ~spacing:16. [ destinations; V.spacer (); capture ]
|> test_id "journal-bottom-controls"
]
()
|> V.Body.static
bottom_controls
~key:"journal-bottom-controls"
~controls:
(V.row ~spacing:16. [ destinations; V.spacer (); capture ]
|> test_id "journal-bottom-controls")
body
else body
;;

Expand Down
3 changes: 2 additions & 1 deletion app/journal_header.mli
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ val feedback
val date_header : title:string -> Journal_view.View.t

val detail
: actions:Journal_view.View.buttons_action list
: capture_enabled:bool
-> on_capture:Journal_view.Event.Handler.t
-> Journal_view.View.Body.t
-> Journal_view.View.Body.t
117 changes: 115 additions & 2 deletions app/journal_routes.ml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ type view =

type detached =
{ block_id : string
; request_generation : int64
; rank : int64
; composer : Journal_detail.retained_composer
}
Expand Down Expand Up @@ -142,14 +143,17 @@ let track_detail_session t detail =

let retain_owner t owner_id =
match Owners.find_opt owner_id t.owners with
| Some (Detail_view { block_id; detail; _ }) ->
| Some (Detail_view { block_id; detail; request_generation }) ->
let t = track_detail_session t detail in
(match Journal_detail.retain_composer detail with
| None -> { t with drafts = Drafts.remove owner_id t.drafts }
| Some composer ->
{ t with
drafts =
Drafts.add owner_id { block_id; composer; rank = t.next_retained } t.drafts
Drafts.add
owner_id
{ block_id; request_generation; composer; rank = t.next_retained }
t.drafts
; next_retained = Int64.succ t.next_retained
})
| _ -> t
Expand All @@ -168,6 +172,115 @@ type retained_drafts =
; next_rank : int64
}

let retained_attachments retained =
Drafts.fold
(fun _ draft items ->
List.rev_append (Journal_detail.retained_attachments draft.composer) items)
retained.composers
[]
;;

let pending_attachments t =
let items =
Drafts.fold
(fun _ draft items ->
List.rev_append (Journal_detail.retained_attachments draft.composer) items)
t.drafts
Comment on lines +185 to +188

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Retire retained attachments after a committed root deletion

When a Detail root has a canceled draft with staged attachments, stage_delete deliberately moves its composer into t.drafts so Undo can restore it. If the deletion commits, however, Subtree_deleted only clears pending_delete; it never removes that retained draft or discards the staged files collected here. Repeatedly deleting roots with attachment drafts therefore leaves stale drafts and temporary journal-import-* copies until the graph or account is torn down; add terminal-delete cleanup while preserving the existing Undo path.

Useful? React with 👍 / 👎.

[]
in
Owners.fold
(fun _ view items ->
match view with
| Detail_view { detail; _ } ->
Option.fold
~none:items
~some:(fun capture ->
List.rev_append (Journal_capture.pending_attachments capture) items)
(Journal_detail.child_capture detail)
| _ -> items)
t.owners
items
;;

let child_capture_at t ~entry_id ~request_generation =
match Owners.find_opt entry_id t.owners with
| Some (Detail_view owner) when owner.request_generation = request_generation ->
Journal_detail.child_capture owner.detail
| _ ->
Option.bind (Drafts.find_opt entry_id t.drafts) (fun draft ->
if draft.request_generation = request_generation
then Some (Journal_detail.retained_capture draft.composer)
else None)
;;

let child_saving_at t ~entry_id ~request_generation =
match Owners.find_opt entry_id t.owners with
| Some (Detail_view owner) when owner.request_generation = request_generation ->
Journal_detail.mode owner.detail = Saving_child
| _ ->
Option.fold
~none:false
~some:(fun draft ->
draft.request_generation = request_generation
&& Journal_detail.retained_saving draft.composer)
(Drafts.find_opt entry_id t.drafts)
;;

let map_child_capture_at t ~entry_id ~request_generation ~f =
match Owners.find_opt entry_id t.owners with
| Some (Detail_view owner) when owner.request_generation = request_generation ->
let next = Journal_detail.map_child_capture owner.detail ~f in
if next == owner.detail
then t
else
{ t with
owners = Owners.add entry_id (Detail_view { owner with detail = next }) t.owners
}
| _ ->
(match Drafts.find_opt entry_id t.drafts with
| Some draft when draft.request_generation = request_generation ->
let composer = Journal_detail.map_retained_capture draft.composer ~f in
if composer == draft.composer
then t
else { t with drafts = Drafts.add entry_id { draft with composer } t.drafts }
| _ -> t)
;;

let commit_delete t ~block_id =
let retired, kept =
Drafts.partition (fun _ draft -> draft.block_id = block_id) t.drafts
in
let files =
Drafts.fold
(fun _ draft files ->
List.rev_append (Journal_detail.retained_attachments draft.composer) files)
retired
[]
in
if Drafts.is_empty retired then t, [] else { t with drafts = kept }, files
;;

let child_attachment_imports t ~child ~parent =
let found =
Drafts.fold
(fun _ draft found ->
match found with
| Some _ -> found
| None ->
Journal_detail.retained_attachment_imports draft.composer ~child ~parent)
t.drafts
None
in
Owners.fold
(fun _ view found ->
match found, view with
| None, Detail_view { detail; _ } ->
Journal_detail.child_attachment_imports detail ~child ~parent
| _ -> found)
t.owners
found
;;

let retain_drafts ~interrupted t =
let t = retain_live_composers t in
{ composers =
Expand Down
Loading
Loading