8181 }
8282 .repo-item : hover .sync-btn { opacity : 1 ; }
8383 .repo-item .syncing .sync-btn { opacity : 1 ; animation : spin 1s linear infinite; }
84+ .repo-item .sync-btn .pushing { opacity : 1 ; color : # 2da44e ; }
8485 @keyframes spin { to { transform : rotate (360deg ); } }
8586 .add-btn {
8687 margin : 8px ;
412413 console . log ( 'Pull complete' ) ;
413414 }
414415
416+ async function pushRepo ( repoId , cloneUrl , branch = 'main' ) {
417+ const dir = `${ SYNC_DIR } /${ repoId } ` ;
418+ const privkey = getPrivkey ( ) ;
419+
420+ if ( ! privkey ) {
421+ throw new Error ( 'No private key configured. Open Settings to add one.' ) ;
422+ }
423+
424+ console . log ( `Pushing ${ repoId } to ${ cloneUrl } ...` ) ;
425+ await git . push ( {
426+ fs,
427+ http,
428+ dir,
429+ url : cloneUrl ,
430+ ref : branch ,
431+ onAuth : async ( url ) => {
432+ // Create NIP-98 token for this URL
433+ const event = {
434+ kind : 27235 ,
435+ created_at : Math . floor ( Date . now ( ) / 1000 ) ,
436+ tags : [
437+ [ 'u' , url ] ,
438+ [ 'method' , 'POST' ] // Git uses POST for push
439+ ] ,
440+ content : ''
441+ } ;
442+ const signed = await signEvent ( event , privkey ) ;
443+ const token = btoa ( JSON . stringify ( signed ) ) ;
444+ return { username : 'nostr' , password : token } ;
445+ }
446+ } ) ;
447+ console . log ( 'Push complete' ) ;
448+ }
449+
450+ async function commitChanges ( repoId , message ) {
451+ const dir = `${ SYNC_DIR } /${ repoId } ` ;
452+ const privkey = getPrivkey ( ) ;
453+ const pubkey = getPubkeyFromPrivkey ( privkey ) ;
454+
455+ // Stage all changes
456+ await git . add ( { fs, dir, filepath : '.' } ) ;
457+
458+ // Create commit
459+ const sha = await git . commit ( {
460+ fs,
461+ dir,
462+ message,
463+ author : {
464+ name : pubkey ? `nostr:${ pubkey . slice ( 0 , 8 ) } ` : 'browser' ,
465+ email : pubkey ? `${ pubkey . slice ( 0 , 8 ) } @nostr` : 'browser@local'
466+ }
467+ } ) ;
468+ console . log ( 'Commit:' , sha ) ;
469+ return sha ;
470+ }
471+
472+ async function writeFile ( repoId , filePath , content ) {
473+ const fullPath = `${ SYNC_DIR } /${ repoId } /${ filePath } ` ;
474+ await pfs . writeFile ( fullPath , content , 'utf8' ) ;
475+ }
476+
415477 async function repoExists ( repoId ) {
416478 try {
417479 await pfs . stat ( `${ SYNC_DIR } /${ repoId } /.git` ) ;
@@ -531,7 +593,7 @@ <h1>🔗 Nostr Git Browser</h1>
531593 ` ;
532594 }
533595
534- function RepoList ( { repos, selected, syncing, onSelect, onSync, onDelete, onAdd } ) {
596+ function RepoList ( { repos, selected, syncing, pushing , onSelect, onSync, onPush , onDelete, onAdd } ) {
535597 return html `
536598 < div class ="sidebar ">
537599 < h2 > Repositories</ h2 >
@@ -544,7 +606,17 @@ <h2>Repositories</h2>
544606 >
545607 < span > 📁</ span >
546608 < span class ="name "> ${ id } </ span >
547- < button class ="sync-btn " onClick =${ ( e ) => { e . stopPropagation ( ) ; onSync ( id ) ; } } > ↻</ button >
609+ < button
610+ class ="sync-btn "
611+ onClick =${ ( e ) => { e . stopPropagation ( ) ; onSync ( id ) ; } }
612+ title ="Pull"
613+ > ↻</ button >
614+ < button
615+ class ="sync-btn ${ pushing === id ? 'pushing' : '' } "
616+ onClick =${ ( e ) => { e . stopPropagation ( ) ; onPush ( id ) ; } }
617+ title ="Push"
618+ style="margin-left: 2px;"
619+ > ↑</ button >
548620 </ div >
549621 ` ) }
550622 ${ Object . keys ( repos ) . length === 0 && html `
@@ -688,11 +760,14 @@ <h2>Add Repository</h2>
688760 return html `< div class ="toast "> ${ message } </ div > ` ;
689761 }
690762
691- function SettingsModal ( { onClose, onToast } ) {
763+ function SettingsModal ( { onClose, onToast, repos } ) {
692764 const [ privkey , setPrivkeyState ] = useState ( getPrivkey ) ;
693765 const [ testUrl , setTestUrl ] = useState ( 'https://melvin.me/public/git/sync/20260202/counter.txt' ) ;
694766 const [ testResult , setTestResult ] = useState ( null ) ;
695767 const [ testing , setTesting ] = useState ( false ) ;
768+ const [ gitTestResult , setGitTestResult ] = useState ( null ) ;
769+ const [ gitTesting , setGitTesting ] = useState ( false ) ;
770+ const [ selectedRepo , setSelectedRepo ] = useState ( Object . keys ( repos || { } ) [ 0 ] || '' ) ;
696771
697772 const pubkey = useMemo ( ( ) => getPubkeyFromPrivkey ( privkey ) , [ privkey ] ) ;
698773 const isValid = pubkey . length === 64 ;
@@ -742,9 +817,41 @@ <h2>Add Repository</h2>
742817 }
743818 } ;
744819
820+ const handleGitTest = async ( ) => {
821+ if ( ! isValid || ! selectedRepo ) {
822+ setGitTestResult ( { success : false , message : 'Need valid key and repo' } ) ;
823+ return ;
824+ }
825+
826+ setGitTesting ( true ) ;
827+ setGitTestResult ( null ) ;
828+
829+ try {
830+ const repo = repos [ selectedRepo ] ;
831+ if ( ! repo ) throw new Error ( 'Repo not found' ) ;
832+
833+ // Create/update a test file with timestamp
834+ const timestamp = new Date ( ) . toISOString ( ) ;
835+ await writeFile ( selectedRepo , 'browser-test.txt' , `Browser push test: ${ timestamp } \n` ) ;
836+
837+ // Commit
838+ await commitChanges ( selectedRepo , `Browser test: ${ timestamp } ` ) ;
839+
840+ // Push
841+ await pushRepo ( selectedRepo , repo . cloneUrl , repo . branch ) ;
842+
843+ setGitTestResult ( { success : true , message : `Pushed to ${ selectedRepo } !` } ) ;
844+ } catch ( err ) {
845+ console . error ( 'Git test error:' , err ) ;
846+ setGitTestResult ( { success : false , message : err . message } ) ;
847+ } finally {
848+ setGitTesting ( false ) ;
849+ }
850+ } ;
851+
745852 return html `
746853 < div class ="modal-overlay " onClick =${ onClose } >
747- < div class ="modal " onClick =${ e => e . stopPropagation ( ) } >
854+ < div class ="modal " onClick =${ e => e . stopPropagation ( ) } style ="max-height: 90vh; overflow-y: auto;" >
748855 < h2 > Settings</ h2 >
749856 < label >
750857 < span > Private Key (hex)</ span >
@@ -785,6 +892,32 @@ <h2>Settings</h2>
785892 ${ testResult . message }
786893 </ div >
787894 ` }
895+ < hr style ="margin: 16px 0; border: none; border-top: 1px solid #d0d7de; " />
896+ < label >
897+ < span > Test Git Push</ span >
898+ < select
899+ value =${ selectedRepo }
900+ onChange =${ e => setSelectedRepo ( e . target . value ) }
901+ style="width: 100%; padding: 8px; border: 1px solid #d0d7de; border-radius: 6px;"
902+ >
903+ ${ Object . keys ( repos || { } ) . map ( id => html `
904+ < option key =${ id } value =${ id } > ${ id } </ option >
905+ ` ) }
906+ </ select >
907+ </ label >
908+ < button
909+ class ="test-btn "
910+ onClick =${ handleGitTest }
911+ disabled =${ ! isValid || ! selectedRepo || gitTesting }
912+ style="margin-top: 8px;"
913+ >
914+ ${ gitTesting ? 'Pushing...' : 'Create Commit & Push' }
915+ </ button >
916+ ${ gitTestResult && html `
917+ < div class ="test-result ${ gitTestResult . success ? 'success' : 'error' } ">
918+ ${ gitTestResult . message }
919+ </ div >
920+ ` }
788921 < div class ="buttons ">
789922 < button type ="button " onClick =${ onClose } > Cancel</ button >
790923 < button type ="submit " onClick =${ handleSave } > Save</ button >
@@ -799,6 +932,7 @@ <h2>Settings</h2>
799932 const [ config , setConfig ] = useState ( loadConfig ) ;
800933 const [ selected , setSelected ] = useState ( null ) ;
801934 const [ syncing , setSyncing ] = useState ( null ) ;
935+ const [ pushing , setPushing ] = useState ( null ) ;
802936 const [ files , setFiles ] = useState ( [ ] ) ;
803937 const [ expanded , setExpanded ] = useState ( new Set ( ) ) ;
804938 const [ selectedFile , setSelectedFile ] = useState ( null ) ;
@@ -922,6 +1056,28 @@ <h2>Settings</h2>
9221056 }
9231057 }
9241058
1059+ async function handlePush ( repoId ) {
1060+ const repo = config . repos [ repoId ] ;
1061+ if ( ! repo ) return ;
1062+
1063+ const privkey = getPrivkey ( ) ;
1064+ if ( ! privkey ) {
1065+ showToast ( 'No private key configured. Open Settings first.' ) ;
1066+ return ;
1067+ }
1068+
1069+ setPushing ( repoId ) ;
1070+ try {
1071+ await pushRepo ( repoId , repo . cloneUrl , repo . branch ) ;
1072+ showToast ( `Pushed: ${ repoId } ` ) ;
1073+ } catch ( err ) {
1074+ console . error ( 'Push error:' , err ) ;
1075+ showToast ( `Push error: ${ err . message } ` ) ;
1076+ } finally {
1077+ setPushing ( null ) ;
1078+ }
1079+ }
1080+
9251081 async function handleToggle ( path ) {
9261082 const newExpanded = new Set ( expanded ) ;
9271083 if ( newExpanded . has ( path ) ) {
@@ -993,8 +1149,10 @@ <h2>Settings</h2>
9931149 repos =${ config . repos }
9941150 selected=${ selected }
9951151 syncing=${ syncing }
1152+ pushing=${ pushing }
9961153 onSelect=${ setSelected }
9971154 onSync=${ syncRepo }
1155+ onPush=${ handlePush }
9981156 onDelete=${ handleDeleteRepo }
9991157 onAdd=${ ( ) => setShowAddModal ( true ) }
10001158 />
@@ -1023,6 +1181,7 @@ <h2>Settings</h2>
10231181 < ${ SettingsModal }
10241182 onClose =${ ( ) => setShowSettingsModal ( false ) }
10251183 onToast=${ showToast }
1184+ repos=${ config . repos }
10261185 />
10271186 ` }
10281187 < ${ Toast } message =${ toast } />
0 commit comments