c5f3bde4 Andy McCall 1 month ago ↶
1
2
3
4
5
6
7
8
use super :: edit:: { after_web_commit, signer_for} ;
9
use super :: settings:: { csrf, require} ;
10
use super :: { lookup_role, Error, Page, RepoShell, Shell} ;
11
use crate :: auth:: { Access, Viewer} ;
12
use crate :: repos:: edits;
13
use crate :: repos:: view:: { self , FileBody} ;
14
use crate :: Shared;
15
use askama:: Template;
16
use axum:: extract:: { Form, Path, State} ;
17
use axum:: response:: { IntoResponse, Redirect, Response} ;
18
use oxy_core:: repo:: FileEdit;
19
use std:: collections:: HashMap;
20
16cb2b3a Andy McCall 1 month ago ↶
21
22
pub fn vscode_link ( app : & Shared, owner : & str , name : & str , bookmark : & str , path : & str ) -> String {
23
use super :: settings:: urlencode;
24
let url = format! ( " {} /{owner} /{name} " , app.config.base_url ( ) .trim_end_matches ( ' /' ) ) ;
129c23ef Andy McCall 1 month ago ↶
25
format! ( " vscode://oxygenhub.oxygen/resolve?url={} &bookmark={} &path={} " , urlencode ( & url) , urlencode ( bookmark) , urlencode ( path) )
16cb2b3a Andy McCall 1 month ago ↶
26
}
27
c5f3bde4 Andy McCall 1 month ago ↶
28
29
# [ derive ( Clone, Debug, PartialEq, Eq ) ]
30
pub enum Piece {
31
32
Plain( String ) ,
33
Region( Region) ,
34
}
35
36
# [ derive ( Clone, Debug, PartialEq, Eq ) ]
37
pub struct Region {
38
39
pub n : usize ,
40
pub sides : Vec< Side> ,
41
}
42
43
# [ derive ( Clone, Debug, PartialEq, Eq ) ]
44
pub struct Side {
45
46
pub label : String,
47
48
pub text : String,
49
50
51
pub deletion : bool ,
52
}
53
54
impl Side {
55
56
pub fn kept ( & self ) -> & str {
57
if self .deletion {
58
" "
59
} else {
60
& self .text
61
}
62
}
63
}
64
65
impl Region {
66
67
pub fn all ( & self ) -> String {
68
self .sides.iter ( ) .map ( Side:: kept) .collect ( )
69
}
70
}
71
72
73
74
pub fn parse ( marked : & str ) -> Option< Vec< Piece> > {
75
let mut out = Vec :: new( ) ;
76
let mut plain = String :: new( ) ;
77
let mut region: Option< Region> = None ;
78
let mut lines = marked.split_inclusive ( ' \n ' ) .peekable ( ) ;
79
while let Some ( line) = lines.next ( ) {
80
let bare = line.trim_end_matches ( ' \n ' ) ;
81
if let Some ( rest) = bare.strip_prefix ( " <<<<<<< oxy conflict " ) {
82
if region.is_some ( ) {
83
return None ;
84
}
85
if ! plain.is_empty ( ) {
86
out.push ( Piece:: Plain( std:: mem:: take( & mut plain) ) ) ;
87
}
88
let ( n, label) = header ( rest) ? ;
89
let mut side = Side { label, text: String :: new( ) , deletion: false } ;
90
if lines.peek ( ) .map ( | l | l.trim_end_matches ( ' \n ' ) ) = = Some ( " -------" ) {
91
lines.next ( ) ;
92
side.deletion = true ;
93
}
94
region = Some ( Region { n, sides: vec! [ side] } ) ;
95
} else if let Some ( rest) = bare.strip_prefix ( " ======= oxy conflict " ) {
96
let r = region.as_mut ( ) ? ;
97
let ( n, label) = header ( rest) ? ;
98
if n ! = r.n {
99
return None ;
100
}
101
r.sides.push ( Side { label, text: String :: new( ) , deletion: false } ) ;
102
} else if bare.starts_with ( " >>>>>>> oxy conflict " ) {
103
out.push ( Piece:: Region( region.take ( ) ? ) ) ;
104
} else {
105
match region.as_mut ( ) {
106
Some ( r) => r.sides.last_mut ( ) ? .text.push_str ( line) ,
107
None => plain.push_str ( line) ,
108
}
109
}
110
}
111
if region.is_some ( ) {
112
return None ;
113
}
114
if ! plain.is_empty ( ) {
115
out.push ( Piece:: Plain( plain) ) ;
116
}
117
Some ( out)
118
}
119
120
121
fn header ( rest : & str ) -> Option< ( usize , String) > {
122
let ( n, tail) = rest.split_once ( " of " ) ? ;
123
let label = tail.split_once ( " : " ) .map ( | ( _, l ) | l.to_string ( ) ) .unwrap_or_default ( ) ;
124
Some ( ( n.trim ( ) .parse ( ) .ok ( ) ? , label) )
125
}
126
127
128
# [ derive ( Clone, Debug, PartialEq, Eq ) ]
129
pub enum Choice {
130
Side( usize ) ,
131
All,
132
None ,
133
Text( String ) ,
134
}
135
136
137
138
pub fn assemble ( pieces : & [Piece], choices : & HashMap< usize , Choice> ) -> Result< String, String> {
139
let mut out = String :: new( ) ;
140
for p in pieces {
141
match p {
142
Piece:: Plain( t) => out.push_str ( t) ,
143
Piece:: Region( r) => match choices.get ( & r.n) {
144
Some ( Choice:: Side( i) ) => {
145
out.push_str ( r.sides.get ( i.wrapping_sub ( 1 ) ) .ok_or_else ( | | format! ( " conflict {} has no side {i} " , r.n) ) ? .kept ( ) )
146
}
147
Some ( Choice:: All) => out.push_str ( & r.all ( ) ) ,
148
Some ( Choice:: None) => { }
149
Some ( Choice:: Text( t) ) => {
150
let t = t.replace ( " \r \n " , " \n " ) ;
151
out.push_str ( & t) ;
152
if ! t.is_empty ( ) & & ! t.ends_with ( ' \n ' ) {
153
out.push ( ' \n ' ) ;
154
}
155
}
156
None => return Err ( format! ( " choose how to resolve conflict {} " , r.n) ) ,
157
} ,
158
}
159
}
160
Ok ( out)
161
}
162
fd958fbc Andy McCall 1 month ago ↶
163
164
# [ derive ( Clone, Debug ) ]
165
pub struct Candidate {
166
pub path : String,
167
pub content : Vec< u8 > ,
168
169
pub preview : Option< String> ,
170
}
171
172
173
174
175
fn name_candidates ( r : & oxy_core:: repo:: Repo, snapshot : & oxy_core:: state:: Snapshot, path : & str ) -> Result< Option< Vec< Candidate> > , String> {
176
use oxy_core:: objects:: NodeKind;
177
use oxy_core:: state:: FileContent;
178
use oxy_core:: tree:: TreeConflict;
179
let by_node = snapshot.tree.by_node ( ) ;
180
for c in & snapshot.tree.conflicts {
181
let TreeConflict:: Name { parent, name, nodes } = c else { continue } ;
182
let full = if parent.is_root ( ) {
183
name.clone ( )
184
} else {
185
match by_node.get ( parent) {
186
Some ( e) => format! ( " {} /{name} " , e.path) ,
187
None => continue ,
188
}
189
} ;
190
if full ! = path {
191
continue ;
192
}
193
let mut out = Vec :: new( ) ;
194
for n in nodes {
195
let Some ( e) = by_node.get ( n) else { continue } ;
196
let f = snapshot.files.get ( & e.path) .ok_or ( " a candidate is missing" ) ? ;
197
if f.kind ! = NodeKind:: File {
198
return Err ( " This conflict is between directories or links; resolve it from a client." .into ( ) ) ;
199
}
200
let content = match & f.content {
201
FileContent:: Text { bytes, .. } => bytes.clone ( ) ,
202
FileContent:: Opaque { blobs } => match blobs.first ( ) {
203
Some ( b) => r.read_blob ( b) .map_err ( | e | e.to_string ( ) ) ? ,
204
None => vec! [ ] ,
205
} ,
206
FileContent:: Dir => continue ,
207
} ;
208
let preview = std:: str:: from_utf8( & content) .ok ( ) .map ( | t | t.lines ( ) .take ( 40 ) .collect:: < Vec< _ > > ( ) .join ( " \n " ) ) ;
209
out.push ( Candidate { path: e.path.clone ( ) , content, preview } ) ;
210
}
211
out.sort_by ( | a , b | a.path.cmp ( & b.path) ) ;
212
return Ok ( Some ( out) ) ;
213
}
214
Ok ( None )
215
}
216
9e5ef6c2 Andy McCall 1 month ago ↶
217
218
219
fn replace_candidates (
220
r : & oxy_core:: repo:: Repo,
221
state : & oxy_core:: state:: State,
222
snapshot : & oxy_core:: state:: Snapshot,
223
path : & str ,
224
) -> Option< Vec< Candidate> > {
225
use oxy_core:: state:: FileContent;
226
let f = snapshot.files.get ( path) ? ;
227
let FileContent:: Opaque { blobs } = & f.content else { return None } ;
228
if blobs.len ( ) < 2 | | f.kind ! = oxy_core:: objects:: NodeKind:: File {
229
return None ;
230
}
231
let writers = state.live_replaces ( & f.node) ;
232
let mut out = Vec :: new( ) ;
233
for ( i, b) in blobs.iter ( ) .enumerate ( ) {
234
let content = r.read_blob ( b) .ok ( ) ? ;
235
let by = writers.iter ( ) .find ( | ( _, n ) | n = = b ) .map ( | ( h , _) | state.label ( h) ) .unwrap_or_default ( ) ;
236
let preview = std:: str:: from_utf8( & content) .ok ( ) .map ( | t | t.lines ( ) .take ( 40 ) .collect:: < Vec< _ > > ( ) .join ( " \n " ) ) ;
237
out.push ( Candidate { path: format! ( " version {} from change {by} " , i + 1 ) , content, preview } ) ;
238
}
239
Some ( out)
240
}
241
fd958fbc Andy McCall 1 month ago ↶
242
243
enum Target {
244
245
Lines( String ) ,
246
247
Name( Vec< Candidate> ) ,
9e5ef6c2 Andy McCall 1 month ago ↶
248
249
Replace( Vec< Candidate> ) ,
fd958fbc Andy McCall 1 month ago ↶
250
}
251
252
# [ derive ( Template ) ]
253
# [ template ( path = " resolve_name.html" ) ]
254
pub struct NamePage {
255
shell : Shell,
256
repo : RepoShell,
257
bookmark : String,
258
path : String,
259
state : String,
260
candidates : Vec< Candidate> ,
9e5ef6c2 Andy McCall 1 month ago ↶
261
262
263
versions : bool ,
16cb2b3a Andy McCall 1 month ago ↶
264
vscode : String,
fd958fbc Andy McCall 1 month ago ↶
265
delegation : Option< String> ,
266
error : Option< String> ,
267
message : String,
268
}
269
c5f3bde4 Andy McCall 1 month ago ↶
270
# [ derive ( Template ) ]
271
# [ template ( path = " resolve.html" ) ]
272
pub struct ResolvePage {
273
shell : Shell,
274
repo : RepoShell,
275
bookmark : String,
276
path : String,
277
state : String,
278
pieces : Vec< Piece> ,
16cb2b3a Andy McCall 1 month ago ↶
279
280
vscode : String,
c5f3bde4 Andy McCall 1 month ago ↶
281
delegation : Option< String> ,
282
error : Option< String> ,
283
message : String,
284
}
285
fd958fbc Andy McCall 1 month ago ↶
286
287
async fn load ( app : & Shared, shell : & Shell, repo : & crate:: repos:: RepoName, bookmark : & str , path : & str ) -> Result< ( String, Target) , Error> {
c5f3bde4 Andy McCall 1 month ago ↶
288
let limit = app.config.limits.render_limit.0 ;
289
let ( b, p) = ( bookmark.to_string ( ) , path.to_string ( ) ) ;
fd958fbc Andy McCall 1 month ago ↶
290
let found = app
c5f3bde4 Andy McCall 1 month ago ↶
291
.repos
292
.read ( repo, move | r, cache| {
fd958fbc Andy McCall 1 month ago ↶
293
let Some ( st) = r.view ( ) ? .bookmarks.get ( & b) .copied ( ) else { return Ok ( None ) } ;
294
let heads = r.store ( ) .get_state ( & st) ? .heads;
295
let loaded = cache.load ( r, & heads) ? ;
296
let names = name_candidates ( r, & loaded.snapshot, & p) ;
9e5ef6c2 Andy McCall 1 month ago ↶
297
let versions = replace_candidates ( r, & loaded.state, & loaded.snapshot, & p) ;
fd958fbc Andy McCall 1 month ago ↶
298
let file = if names.as_ref ( ) .is_ok_and ( | n | n.is_some ( ) ) {
299
None
300
} else {
301
let target = view:: resolve( r, cache, & b) ? ;
302
Some ( view:: file( r, cache, target, & p, limit) )
303
} ;
9e5ef6c2 Andy McCall 1 month ago ↶
304
Ok ( Some ( ( st.to_hex ( ) , names, file, versions) ) )
c5f3bde4 Andy McCall 1 month ago ↶
305
} )
306
.await
307
.map_err ( | e | Error:: from_repo( shell.clone ( ) , e) ) ? ;
9e5ef6c2 Andy McCall 1 month ago ↶
308
let Some ( ( state, names, file, versions) ) = found else {
309
return Err ( Error:: bad( shell.clone ( ) , " Conflicts are resolved on a bookmark." ) ) ;
310
} ;
311
if let Some ( v) = versions {
312
return Ok ( ( state, Target:: Replace( v) ) ) ;
313
}
fd958fbc Andy McCall 1 month ago ↶
314
match names {
315
Ok ( Some ( c) ) => return Ok ( ( state, Target:: Name( c) ) ) ,
316
Err ( msg) => return Err ( Error:: bad( shell.clone ( ) , & msg) ) ,
317
Ok ( None ) => { }
318
}
319
let file = match file {
320
Some ( Ok ( f) ) => f,
321
Some ( Err ( e) ) => return Err ( Error:: from_repo( shell.clone ( ) , e) ) ,
322
None => return Err ( Error:: bad( shell.clone ( ) , " Nothing to resolve there." ) ) ,
323
} ;
c5f3bde4 Andy McCall 1 month ago ↶
324
match file.body {
fd958fbc Andy McCall 1 month ago ↶
325
FileBody:: Text( t) if file.conflicted => Ok ( ( state, Target:: Lines( t) ) ) ,
c5f3bde4 Andy McCall 1 month ago ↶
326
FileBody:: Text( _ ) => Err ( Error:: bad( shell.clone ( ) , " That file has no conflicts on this bookmark." ) ) ,
fd958fbc Andy McCall 1 month ago ↶
327
_ => Err ( Error:: bad( shell.clone ( ) , " Only text files within the render limit, and name conflicts, are resolved in the browser." ) ) ,
c5f3bde4 Andy McCall 1 month ago ↶
328
}
329
}
330
331
# [ allow ( clippy::too_many_arguments ) ]
332
async fn page (
333
app : & Shared,
334
viewer : & Viewer,
335
owner : & str ,
336
name : & str ,
337
bookmark : & str ,
338
path : & str ,
339
error : Option< String> ,
340
message : Option< String> ,
341
) -> Result< Page< ResolvePage> , Error> {
342
let shell = Shell:: new( app, & format! ( " /{owner} /{name} /resolve/{bookmark} /{path} " ) , viewer) ;
343
let me = require ( viewer, & shell) ? ;
344
let ( repo, row, role) = lookup_role ( app, & shell, viewer, owner, name, Access:: Write) .await? ;
fd958fbc Andy McCall 1 month ago ↶
345
let ( state, target) = load ( app, & shell, & repo, bookmark, path) .await? ;
346
let text = match target {
347
Target:: Lines( t) => t,
9e5ef6c2 Andy McCall 1 month ago ↶
348
Target:: Name( _ ) | Target:: Replace( _ ) => return Err ( Error:: bad( shell, " That conflict is resolved by keeping a candidate." ) ) ,
fd958fbc Andy McCall 1 month ago ↶
349
} ;
c5f3bde4 Andy McCall 1 month ago ↶
350
let Some ( pieces) = parse ( & text) else {
351
return Err ( Error:: bad( shell, " The conflict markers could not be read; resolve the file in the plain editor." ) ) ;
352
} ;
353
let delegation = signer_for ( app, me.account.id) .await.err ( ) ;
354
Ok ( Page( ResolvePage {
355
shell,
356
repo: RepoShell:: new( app, & row, " conflicts" ) .with_role ( role) ,
357
bookmark: bookmark.to_string ( ) ,
358
path: path.to_string ( ) ,
359
state,
360
pieces,
16cb2b3a Andy McCall 1 month ago ↶
361
vscode: vscode_link ( app, owner, name, bookmark, path) ,
c5f3bde4 Andy McCall 1 month ago ↶
362
delegation,
363
error,
364
message: message.unwrap_or_else ( | | format! ( " Resolve the conflicts in {path} " ) ) ,
365
} ) )
366
}
367
368
pub async fn form (
369
State( app ) : State< Shared> ,
370
viewer : Viewer,
371
Path( (owner , name , bookmark , path ) ) : Path< ( String, String, String, String) > ,
372
) -> Result< Response, Error> {
fd958fbc Andy McCall 1 month ago ↶
373
let shell = Shell:: new( & app, & format! ( " /{owner} /{name} /resolve/{bookmark} /{path} " ) , & viewer) ;
c5f3bde4 Andy McCall 1 month ago ↶
374
let ( repo, _ , _ ) = lookup_role ( & app, & shell, & viewer, & owner, & name, Access:: Write) .await? ;
fd958fbc Andy McCall 1 month ago ↶
375
match load ( & app, & shell, & repo, & bookmark, & path) .await? .1 {
376
377
Target:: Lines( text) if parse ( & text) .is_none ( ) => {
378
Ok ( Redirect:: to( & format! ( " /{owner} /{name} /edit/{bookmark} /{path} " ) ) .into_response ( ) )
379
}
380
Target:: Lines( _ ) => Ok ( page ( & app, & viewer, & owner, & name, & bookmark, & path, None , None ) .await? .into_response ( ) ) ,
9e5ef6c2 Andy McCall 1 month ago ↶
381
Target:: Name( _ ) | Target:: Replace( _ ) => {
382
Ok ( name_page ( & app, & viewer, & owner, & name, & bookmark, & path, None , None ) .await? .into_response ( ) )
383
}
c5f3bde4 Andy McCall 1 month ago ↶
384
}
385
}
386
fd958fbc Andy McCall 1 month ago ↶
387
# [ allow ( clippy::too_many_arguments ) ]
388
async fn name_page (
389
app : & Shared,
390
viewer : & Viewer,
391
owner : & str ,
392
name : & str ,
393
bookmark : & str ,
394
path : & str ,
395
error : Option< String> ,
396
message : Option< String> ,
397
) -> Result< Page< NamePage> , Error> {
398
let shell = Shell:: new( app, & format! ( " /{owner} /{name} /resolve/{bookmark} /{path} " ) , viewer) ;
399
let me = require ( viewer, & shell) ? ;
400
let ( repo, row, role) = lookup_role ( app, & shell, viewer, owner, name, Access:: Write) .await? ;
401
let ( state, target) = load ( app, & shell, & repo, bookmark, path) .await? ;
9e5ef6c2 Andy McCall 1 month ago ↶
402
let ( candidates, versions) = match target {
403
Target:: Name( c) => ( c, false ) ,
404
Target:: Replace( c) => ( c, true ) ,
405
Target:: Lines( _ ) => return Err ( Error:: bad( shell, " That conflict is resolved region by region." ) ) ,
406
} ;
fd958fbc Andy McCall 1 month ago ↶
407
let delegation = signer_for ( app, me.account.id) .await.err ( ) ;
408
Ok ( Page( NamePage {
409
shell,
410
repo: RepoShell:: new( app, & row, " conflicts" ) .with_role ( role) ,
411
bookmark: bookmark.to_string ( ) ,
412
path: path.to_string ( ) ,
413
state,
414
candidates,
9e5ef6c2 Andy McCall 1 month ago ↶
415
versions,
16cb2b3a Andy McCall 1 month ago ↶
416
vscode: vscode_link ( app, owner, name, bookmark, path) ,
fd958fbc Andy McCall 1 month ago ↶
417
delegation,
418
error,
9e5ef6c2 Andy McCall 1 month ago ↶
419
message: message.unwrap_or_else ( | | {
420
if versions {
421
format! ( " Resolve the conflicting versions of {path} " )
422
} else {
423
format! ( " Resolve the name conflict at {path} " )
424
}
425
} ) ,
fd958fbc Andy McCall 1 month ago ↶
426
} ) )
427
}
428
c5f3bde4 Andy McCall 1 month ago ↶
429
pub async fn save (
430
State( app ) : State< Shared> ,
431
viewer : Viewer,
432
Path( (owner , name , bookmark , path ) ) : Path< ( String, String, String, String) > ,
433
Form( f) : Form< HashMap< String, String> > ,
434
) -> Result< Response, Error> {
435
let shell = Shell:: new( & app, & format! ( " /{owner} /{name} /resolve/{bookmark} /{path} " ) , & viewer) ;
436
let me = require ( & viewer, & shell) ? ;
437
csrf ( & viewer, f.get ( " _csrf" ) .map ( String :: as_str) .unwrap_or ( " " ) , & shell) ? ;
438
let ( repo, row, _ ) = lookup_role ( & app, & shell, & viewer, & owner, & name, Access:: Write) .await? ;
439
let message = f.get ( " message" ) .map ( | m | m.trim ( ) .to_string ( ) ) .unwrap_or_default ( ) ;
440
let again = | msg : String| {
441
let ( app, viewer, owner, name, bookmark, path, message) =
442
( app.clone ( ) , viewer.clone ( ) , owner.clone ( ) , name.clone ( ) , bookmark.clone ( ) , path.clone ( ) , message.clone ( ) ) ;
443
async move { page ( & app, & viewer, & owner, & name, & bookmark, & path, Some ( msg) , Some ( message) ) .await.map ( IntoResponse:: into_response) }
444
} ;
fd958fbc Andy McCall 1 month ago ↶
445
let ( state, target) = load ( & app, & shell, & repo, & bookmark, & path) .await? ;
446
let stale = f.get ( " state" ) ! = Some ( & state) ;
447
let text = match target {
448
Target:: Lines( t) => t,
9e5ef6c2 Andy McCall 1 month ago ↶
449
Target:: Name( candidates) | Target:: Replace( candidates) if candidates.is_empty ( ) => {
450
return again ( " There are no candidates to keep." .into ( ) ) .await;
451
}
452
Target:: Replace( candidates) => {
453
let again = | msg : String| {
454
let ( app, viewer, owner, name, bookmark, path, message) =
455
( app.clone ( ) , viewer.clone ( ) , owner.clone ( ) , name.clone ( ) , bookmark.clone ( ) , path.clone ( ) , message.clone ( ) ) ;
456
async move {
457
name_page ( & app, & viewer, & owner, & name, & bookmark, & path, Some ( msg) , Some ( message) )
458
.await
459
.map ( IntoResponse:: into_response)
460
}
461
} ;
462
if stale {
463
return again ( format! ( " {bookmark} has moved since this page was loaded; the versions below are as they are now." ) ) .await;
464
}
465
let Some ( keep) = f.get ( " keep" ) .and_then ( | k | k.parse:: < usize > ( ) .ok ( ) ) .and_then ( | k | candidates.get ( k) ) else {
466
return again ( " Choose the version to keep." .into ( ) ) .await;
467
} ;
468
if message.is_empty ( ) {
469
return again ( " Describe the resolution." .into ( ) ) .await;
470
}
471
let signer = match signer_for ( & app, me.account.id) .await {
472
Ok ( s) => s,
473
Err ( msg) => return again ( msg) .await,
474
} ;
475
476
let edit = vec! [ FileEdit { path: path.clone ( ) , content: Some ( keep.content.clone ( ) ) } ] ;
477
let done = match edits:: commit( & app.repos, & repo, bookmark.clone ( ) , bookmark.clone ( ) , edit, message.clone ( ) , signer) .await {
478
Ok ( d) => d,
479
Err ( e) => return again ( e.to_string ( ) ) .await,
480
} ;
481
after_web_commit ( & app, & me.account, & row, & owner, & name, & done, & message) .await;
5e6d6484 Andy McCall 1 month ago ↶
482
let url = format! ( " /{owner} /{name} /change/{} " , done.change.to_hex ( ) ) ;
483
app.rooms.landed ( & room ( & row, & bookmark, & path) , & url, & me.account.username) ;
484
return Ok ( Redirect:: to( & url) .into_response ( ) ) ;
9e5ef6c2 Andy McCall 1 month ago ↶
485
}
fd958fbc Andy McCall 1 month ago ↶
486
Target:: Name( candidates) => {
487
let again = | msg : String| {
488
let ( app, viewer, owner, name, bookmark, path, message) =
489
( app.clone ( ) , viewer.clone ( ) , owner.clone ( ) , name.clone ( ) , bookmark.clone ( ) , path.clone ( ) , message.clone ( ) ) ;
490
async move {
491
name_page ( & app, & viewer, & owner, & name, & bookmark, & path, Some ( msg) , Some ( message) )
492
.await
493
.map ( IntoResponse:: into_response)
494
}
495
} ;
496
if stale {
497
return again ( format! ( " {bookmark} has moved since this page was loaded; the candidates below are as they are now." ) ) .await;
498
}
499
let Some ( keep) = f.get ( " keep" ) .and_then ( | k | k.parse:: < usize > ( ) .ok ( ) ) .and_then ( | k | candidates.get ( k) ) else {
500
return again ( " Choose the candidate to keep." .into ( ) ) .await;
501
} ;
502
if message.is_empty ( ) {
503
return again ( " Describe the resolution." .into ( ) ) .await;
504
}
505
let signer = match signer_for ( & app, me.account.id) .await {
506
Ok ( s) => s,
507
Err ( msg) => return again ( msg) .await,
508
} ;
509
510
511
512
let mut edit: Vec< FileEdit> = candidates.iter ( ) .map ( | c | FileEdit { path: c.path.clone ( ) , content: None } ) .collect ( ) ;
513
edit.push ( FileEdit { path: path.clone ( ) , content: Some ( keep.content.clone ( ) ) } ) ;
514
let done = match edits:: commit( & app.repos, & repo, bookmark.clone ( ) , bookmark.clone ( ) , edit, message.clone ( ) , signer) .await {
515
Ok ( d) => d,
516
Err ( e) => return again ( e.to_string ( ) ) .await,
517
} ;
518
after_web_commit ( & app, & me.account, & row, & owner, & name, & done, & message) .await;
5e6d6484 Andy McCall 1 month ago ↶
519
let url = format! ( " /{owner} /{name} /change/{} " , done.change.to_hex ( ) ) ;
520
app.rooms.landed ( & room ( & row, & bookmark, & path) , & url, & me.account.username) ;
521
return Ok ( Redirect:: to( & url) .into_response ( ) ) ;
fd958fbc Andy McCall 1 month ago ↶
522
}
523
} ;
524
if stale {
c5f3bde4 Andy McCall 1 month ago ↶
525
return again ( format! ( " {bookmark} has moved since this page was loaded; the conflicts below are as they are now." ) ) .await;
526
}
527
let Some ( pieces) = parse ( & text) else { return again ( " The conflict markers could not be read." .into ( ) ) .await } ;
528
let mut choices = HashMap:: new( ) ;
529
for p in & pieces {
530
if let Piece:: Region( r) = p {
531
let choice = match f.get ( & format! ( " r{} " , r.n) ) .map ( String :: as_str) {
532
Some ( " all" ) => Choice:: All,
533
Some ( " none" ) => Choice:: None,
534
Some ( " text" ) => Choice:: Text( f.get ( & format! ( " t{} " , r.n) ) .cloned ( ) .unwrap_or_default ( ) ) ,
535
Some ( s) => match s.strip_prefix ( " side" ) .and_then ( | i | i.parse ( ) .ok ( ) ) {
536
Some ( i) => Choice:: Side( i) ,
537
None => continue ,
538
} ,
539
None => continue ,
540
} ;
541
choices.insert ( r.n, choice) ;
542
}
543
}
544
let content = match assemble ( & pieces, & choices) {
545
Ok ( c) => c,
546
Err ( msg) => return again ( msg) .await,
547
} ;
548
if message.is_empty ( ) {
549
return again ( " Describe the resolution." .into ( ) ) .await;
550
}
551
let signer = match signer_for ( & app, me.account.id) .await {
552
Ok ( s) => s,
553
Err ( msg) => return again ( msg) .await,
554
} ;
555
let edit = vec! [ FileEdit { path: path.clone ( ) , content: Some ( content.into_bytes ( ) ) } ] ;
556
let done = match edits:: commit( & app.repos, & repo, bookmark.clone ( ) , bookmark.clone ( ) , edit, message.clone ( ) , signer) .await {
557
Ok ( d) => d,
558
Err ( e) => return again ( e.to_string ( ) ) .await,
559
} ;
560
after_web_commit ( & app, & me.account, & row, & owner, & name, & done, & message) .await;
5e6d6484 Andy McCall 1 month ago ↶
561
let url = format! ( " /{owner} /{name} /change/{} " , done.change.to_hex ( ) ) ;
562
app.rooms.landed ( & room ( & row, & bookmark, & path) , & url, & me.account.username) ;
563
Ok ( Redirect:: to( & url) .into_response ( ) )
c5f3bde4 Andy McCall 1 month ago ↶
564
}
565
566
# [ cfg ( test ) ]
567
mod tests {
568
use super :: * ;
569
570
const MARKED : & str = " a\n <<<<<<< oxy conflict 1 of 2, side 1 of 2: change 0202\n from-2\n ======= oxy conflict 1 of 2, side 2 of 2: change 0303\n from-3\n more-3\n >>>>>>> oxy conflict 1 of 2 end\n b\n <<<<<<< oxy conflict 2 of 2, side 1 of 2: deleted by change 0404\n -------\n gone\n ======= oxy conflict 2 of 2, side 2 of 2: change 0505\n added\n >>>>>>> oxy conflict 2 of 2 end\n c\n " ;
571
572
# [ test ]
573
fn markers_parse_into_regions_and_sides ( ) {
574
let p = parse ( MARKED ) .unwrap ( ) ;
575
assert_eq! ( p.len ( ) , 5 ) ;
576
assert_eq! ( p[ 0 ] , Piece:: Plain( " a\n " .into ( ) ) ) ;
577
let Piece:: Region( r1) = & p[ 1 ] else { panic! ( ) } ;
578
assert_eq! ( ( r1.n, r1.sides.len ( ) ) , ( 1 , 2 ) ) ;
579
assert_eq! ( ( r1.sides[ 0 ] .label.as_str ( ) , r1.sides[ 0 ] .text.as_str ( ) ) , ( " change 0202" , " from-2\n " ) ) ;
580
assert_eq! ( r1.sides[ 1 ] .text, " from-3\n more-3\n " ) ;
581
let Piece:: Region( r2) = & p[ 3 ] else { panic! ( ) } ;
582
assert! ( r2.sides[ 0 ] .deletion & & r2.sides[ 0 ] .text = = " gone\n " & & r2.sides[ 0 ] .kept ( ) .is_empty ( ) ) ;
583
assert_eq! ( r2.sides[ 0 ] .label, " deleted by change 0404" ) ;
584
assert_eq! ( p[ 4 ] , Piece:: Plain( " c\n " .into ( ) ) ) ;
585
assert! ( parse ( " <<<<<<< oxy conflict 1 of 1, side 1 of 2: change x\n no end\n " ) .is_none ( ) ) ;
586
assert_eq! ( parse ( " plain\n " ) .unwrap ( ) , vec! [ Piece:: Plain( " plain\n " .into ( ) ) ] ) ;
587
}
588
589
# [ test ]
590
fn choices_assemble_the_file ( ) {
591
let p = parse ( MARKED ) .unwrap ( ) ;
592
let pick = | a : Choice, b : Choice| assemble ( & p, & HashMap:: from( [ ( 1 , a) , ( 2 , b) ] ) ) ;
593
assert_eq! ( pick ( Choice:: Side( 2 ) , Choice:: Side( 1 ) ) .unwrap ( ) , " a\n from-3\n more-3\n b\n c\n " , " the deletion wins: nothing" ) ;
594
assert_eq! ( pick ( Choice:: All, Choice:: Side( 2 ) ) .unwrap ( ) , " a\n from-2\n from-3\n more-3\n b\n added\n c\n " ) ;
595
assert_eq! ( pick ( Choice:: None, Choice:: Text( " mine" .into ( ) ) ) .unwrap ( ) , " a\n b\n mine\n c\n " , " own text ends its line" ) ;
596
assert! ( pick ( Choice:: Side( 3 ) , Choice:: None) .unwrap_err ( ) .contains ( " no side 3" ) ) ;
597
assert! ( assemble ( & p, & HashMap:: from( [ ( 1 , Choice:: None) ] ) ) .unwrap_err ( ) .contains ( " conflict 2" ) ) ;
5e6d6484 Andy McCall 1 month ago ↶
598
}
599
}
600
601
602
603
fn room ( row : & crate:: db:: repos:: RepoRow, bookmark : & str , path : & str ) -> crate:: live:: RoomKey {
604
crate :: live:: RoomKey { repo_id: row.id, bookmark: bookmark.to_string ( ) , path: path.to_string ( ) }
605
}
606
607
# [ derive ( serde::Deserialize ) ]
608
pub struct LiveQuery {
609
610
client : String,
611
}
612
613
614
615
616
617
pub async fn live_events (
618
State( app ) : State< Shared> ,
619
viewer : Viewer,
620
Path( (owner , name , bookmark , path ) ) : Path< ( String, String, String, String) > ,
621
axum:: extract:: Query( q) : axum:: extract:: Query< LiveQuery> ,
622
) -> Result< axum:: response:: Sse< impl futures:: Stream< Item = Result< axum:: response:: sse:: Event, std:: convert:: Infallible> > > , Error> {
623
use futures:: StreamExt;
624
let shell = Shell:: new( & app, & format! ( " /{owner} /{name} /resolve/{bookmark} /{path} " ) , & viewer) ;
625
let me = require ( & viewer, & shell) ? ;
626
let ( _ , row, _ ) = lookup_role ( & app, & shell, & viewer, & owner, & name, Access:: Write) .await? ;
627
let client: String = q.client.chars ( ) .filter ( | c | c.is_ascii_alphanumeric ( ) | | * c = = ' -' ) .take ( 64 ) .collect ( ) ;
628
if client.is_empty ( ) {
629
return Err ( Error:: bad( shell, " A live view needs a client id." ) ) ;
c5f3bde4 Andy McCall 1 month ago ↶
630
}
5e6d6484 Andy McCall 1 month ago ↶
631
let key = room ( & row, & bookmark, & path) ;
632
let ( rx, snapshot) = app.rooms.join ( key.clone ( ) , & client, & me.account.username) ;
633
let seat = app.rooms.seat ( key, & client) ;
634
let event = | e : & crate:: live:: LiveEvent| {
635
let kind = match e {
636
crate :: live:: LiveEvent:: Snapshot { .. } => " snapshot" ,
637
crate :: live:: LiveEvent:: Presence { .. } => " presence" ,
638
crate :: live:: LiveEvent:: Choice { .. } => " choice" ,
639
crate :: live:: LiveEvent:: Landed { .. } => " landed" ,
640
} ;
641
Ok ( axum:: response:: sse:: Event:: default( ) .event ( kind) .data ( serde_json:: to_string( e) .unwrap_or_default ( ) ) )
642
} ;
643
let first = futures:: stream:: once( std:: future:: ready( event ( & snapshot) ) ) ;
644
let rest = tokio_stream:: wrappers:: BroadcastStream:: new( rx) .filter_map ( move | item| {
645
646
let _held = & seat;
647
std:: future:: ready( item.ok ( ) .map ( | e | event ( & e) ) )
648
} ) ;
649
Ok ( axum:: response:: Sse:: new( first.chain ( rest) ) .keep_alive ( axum:: response:: sse:: KeepAlive:: default( ) ) )
650
}
651
652
# [ derive ( serde::Deserialize ) ]
653
pub struct LiveChoice {
654
#[serde(rename = "_csrf")]
655
csrf_token : String,
656
client : String,
657
658
region : String,
659
choice : String,
660
#[serde(default)]
661
text : String,
662
}
663
664
665
666
667
pub async fn live_choose (
668
State( app ) : State< Shared> ,
669
viewer : Viewer,
670
Path( (owner , name , bookmark , path ) ) : Path< ( String, String, String, String) > ,
671
axum:: Json( c) : axum:: Json< LiveChoice> ,
672
) -> Result< axum:: http:: StatusCode, Error> {
673
let shell = Shell:: new( & app, & format! ( " /{owner} /{name} /resolve/{bookmark} /{path} " ) , & viewer) ;
674
let me = require ( & viewer, & shell) ? ;
675
csrf ( & viewer, & c.csrf_token, & shell) ? ;
676
let ( _ , row, _ ) = lookup_role ( & app, & shell, & viewer, & owner, & name, Access:: Write) .await? ;
677
let valid_region = c.region = = " keep" | | c.region.parse:: < usize > ( ) .is_ok ( ) ;
678
let valid_choice = matches! ( c.choice.as_str ( ) , " all" | " none" | " text" )
679
| | c.choice.strip_prefix ( " side" ) .is_some_and ( | i | i.parse:: < usize > ( ) .is_ok ( ) )
680
| | ( c.region = = " keep" & & c.choice.parse:: < usize > ( ) .is_ok ( ) ) ;
681
if ! valid_region | | ! valid_choice | | c.text.len ( ) > 1 < < 20 {
682
return Err ( Error:: bad( shell, " That is not a choice." ) ) ;
683
}
684
let choice = crate :: live:: Choice { choice: c.choice, text: c.text, by: me.account.username.clone ( ) } ;
685
Ok ( if app.rooms.choose ( & room ( & row, & bookmark, & path) , & c.client, & c.region, choice) {
686
axum:: http:: StatusCode:: NO_CONTENT
687
} else {
688
axum:: http:: StatusCode:: GONE
689
} )
c5f3bde4 Andy McCall 1 month ago ↶
690
}