<?php
error_reporting(E_ALL);
ini_set('display_errors', 1);
session_start();
if (empty($_SESSION['csrftoken'])) {
$_SESSION['csrftoken'] = bin2hex(random_bytes(16));
}
$CSRF = $_SESSION['csrftoken'];
$file_path = isset($_GET['file']) ? $_GET['file'] : '';
$allowed_dir = realpath('.');
if (empty($file_path)) die("No file specified.");
$file_path = realpath($file_path);
if ($file_path === false || strpos($file_path, $allowed_dir) !== 0) die("Access denied.");
if (!file_exists($file_path)) die("File not found.");
if (!is_file($file_path)) die("Not a file.");
$file_content = file_get_contents($file_path);
$file_extension = strtolower(pathinfo($file_path, PATHINFO_EXTENSION));
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (($_POST['csrf'] ?? '') !== $CSRF) die("CSRF failed");
if (isset($_POST['action']) && $_POST['action'] === 'save') {
file_put_contents($file_path, $_POST['content'] ?? '');
$_SESSION['flash'] = "Saved!";
header("Location: " . $_SERVER['REQUEST_URI']);
exit;
}
}
$file_name = basename($file_path);
?><!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1">
<title><?= htmlspecialchars($file_name) ?></title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/ace/1.32.9/ace.js"></script>
<script src="fold-finder.js"></script>
<style>
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%;overflow:hidden}
body{display:flex;flex-direction:column;background:#0f172a;color:#e5e7eb;font:13px system-ui,sans-serif}
.bar{display:flex;gap:4px;padding:6px;background:#1e293b;border-bottom:1px solid #334155;flex-wrap:wrap}
.bar button,.bar a{padding:6px 10px;background:#0f172a;color:#e5e7eb;border:1px solid #475569;border-radius:4px;font-size:13px;text-decoration:none;cursor:pointer;white-space:nowrap}
.bar button:active{background:#334155}
.spacer{flex:1;min-width:10px}
.find{display:flex;gap:4px;padding:6px;background:#1e293b;border-bottom:1px solid #334155}
.find input{flex:1;padding:6px;background:#0f172a;color:#e5e7eb;border:1px solid #475569;border-radius:4px}
.find button{padding:6px 12px;background:#0f172a;color:#e5e7eb;border:1px solid #475569;border-radius:4px}
.find .count{padding:6px;color:#94a3b8;font-size:12px}
#editor{flex:1;width:100%;height:100%}
.msg{padding:6px;background:#166534;color:#dcfce7;font-size:12px}
.match_marker{position:absolute;background:rgba(255,224,102,.25);border:1px solid rgba(255,224,102,.4)}
.match_marker_current{position:absolute;background:rgba(96,165,250,.4);border:1px solid rgba(96,165,250,.9)}
.ace_selection{background:rgba(34,211,238,.35)!important}
.ace_selected-word{background:rgba(148,163,184,.2)!important}
.overlay{position:fixed;inset:0;background:rgba(0,0,0,.7);display:none;align-items:center;justify-content:center;z-index:1000}
.overlay.open{display:flex}
.modal{background:#1e293b;border:1px solid #475569;border-radius:8px;width:90%;max-width:500px;max-height:80vh;display:flex;flex-direction:column}
.modal-head{display:flex;justify-content:space-between;align-items:center;padding:10px;border-bottom:1px solid #475569}
.modal-head h3{font-size:14px;font-weight:600}
.modal-head button{background:transparent;border:none;color:#e5e7eb;font-size:20px;cursor:pointer;padding:0;width:24px;height:24px;line-height:20px}
.modal-body{padding:10px;flex:1;overflow:auto}
.modal-body select{width:100%;padding:8px;background:#0f172a;color:#e5e7eb;border:1px solid #475569;border-radius:4px;margin-bottom:8px;font-size:13px}
.modal-body textarea{width:100%;min-height:200px;background:#0f172a;color:#e5e7eb;border:1px solid #475569;border-radius:4px;padding:8px;font:13px monospace;resize:vertical}
.modal-foot{padding:10px;border-top:1px solid #475569;display:flex;gap:6px;justify-content:flex-end}
.modal-foot button{padding:8px 16px;background:#0f172a;color:#e5e7eb;border:1px solid #475569;border-radius:4px;cursor:pointer}
.modal-foot button:active{background:#334155}
.status{margin-top:8px;padding:6px;background:#0f172a;border:1px solid #475569;border-radius:4px;font-size:12px;color:#94a3b8;min-height:32px}
</style>
</head>
<body>
<?php if (!empty($_SESSION['flash'])): ?>
<div class="msg"><?= htmlspecialchars($_SESSION['flash']); $_SESSION['flash'] = null; ?></div>
<?php endif; ?>
<div class="bar">
<button id="saveBtn" onclick="save()">Save</button>
<button id="selectBtn" onclick="selectFold()">Select Fold</button>
<button id="findPasteBtn" onclick="openFindPaste()">Find</button>
<a href="siteExplorer.php?dir=<?= urlencode(dirname($file_path)) ?>">Back</a>
<div class="spacer"></div>
<span style="font-size:12px;color:#94a3b8"><?= htmlspecialchars($file_name) ?></span>
</div>
<div class="find">
<input id="query" placeholder="Search...">
<button onclick="prev()">◀</button>
<button onclick="next()">▶</button>
<span class="count" id="count"></span>
</div>
<div id="editor"></div>
<form id="form" method="post" style="display:none">
<input type="hidden" name="csrf" value="<?= htmlspecialchars($CSRF) ?>">
<input type="hidden" name="action" value="save">
<input type="hidden" name="content" id="content">
</form>
<div id="overlay" class="overlay">
<div class="modal">
<div class="modal-head">
<h3>Find Pasted Code</h3>
<button onclick="closeFindPaste()">×</button>
</div>
<div class="modal-body">
<select id="langSelect">
<option value="auto">Auto-detect</option>
<option value="js">JavaScript</option>
<option value="php">PHP</option>
<option value="html">HTML</option>
<option value="python">Python</option>
</select>
<textarea id="pasteArea" placeholder="Paste function or code block here..."></textarea>
<div class="status" id="findStatus">Paste code and click Find to locate it</div>
</div>
<div class="modal-foot">
<button onclick="findPasted()">Find</button>
<button onclick="closeFindPaste()">Close</button>
</div>
</div>
</div>
<script>
const ed = ace.edit('editor');
ed.setTheme('ace/theme/monokai');
ed.setOptions({fontSize:14,showPrintMargin:false,wrap:true});
ed.setValue(<?= json_encode($file_content) ?>,-1);
const modes={php:'php',html:'html',css:'css',js:'javascript',json:'json',py:'python',sql:'sql',md:'markdown'};
const mode=modes['<?= $file_extension ?>']||'text';
if(mode!=='text')ed.getSession().setMode('ace/mode/'+mode);
function save(){
document.getElementById('content').value=ed.getValue();
document.getElementById('form').submit();
}
ed.commands.addCommand({
name:'save',
bindKey:{win:'Ctrl-S',mac:'Command-S'},
exec:()=>save()
});
// Find pasted overlay
function openFindPaste(){
document.getElementById('overlay').classList.add('open');
document.getElementById('pasteArea').focus();
document.getElementById('findStatus').textContent = 'Paste code and click Find to locate it';
}
function closeFindPaste(){
document.getElementById('overlay').classList.remove('open');
}
function findPasted(){
const pastedText = document.getElementById('pasteArea').value.trim();
if(!pastedText){
document.getElementById('findStatus').textContent = 'Please paste some code first';
return;
}
let lang = document.getElementById('langSelect').value;
if(lang === 'auto'){
lang = FoldFinder.detectLanguage(pastedText);
}
const fullText = ed.getValue();
const fold = FoldFinder.findFold(fullText, pastedText, lang);
if(!fold){
document.getElementById('findStatus').textContent = 'Could not find matching code block';
return;
}
// Convert character positions to row/col
const beforeStart = fullText.slice(0, fold.start);
const beforeEnd = fullText.slice(0, fold.end);
const startRow = (beforeStart.match(/\n/g) || []).length;
const startCol = fold.start - beforeStart.lastIndexOf('\n') - 1;
const endRow = (beforeEnd.match(/\n/g) || []).length;
const endCol = fold.end - beforeEnd.lastIndexOf('\n') - 1;
// Select the fold
const R = ace.require('ace/range').Range;
const range = new R(startRow, startCol, endRow, endCol);
ed.selection.setRange(range);
ed.scrollToLine(startRow, true, true, ()=>{});
document.getElementById('findStatus').textContent = `Found at line ${startRow + 1} (${Math.round(fold.matchRatio * 100)}% match, ${lang})`;
setTimeout(() => closeFindPaste(), 1500);
}
// === TOKEN-AWARE FOLD SELECTION ===
let lastCursorPos = null;
let lastFoldIndex = -1;
let cachedFolds = [];
// Reset cache when file changes so we never use stale folds
ed.getSession().on('change', () => {
cachedFolds = [];
lastCursorPos = null;
lastFoldIndex = -1;
});
function selectFold() {
const pos = ed.getCursorPosition();
const session = ed.getSession();
const R = ace.require('ace/range').Range;
// Recompute folds if cursor row changed OR no cache
if (!lastCursorPos || lastCursorPos.row !== pos.row || !cachedFolds.length) {
cachedFolds = getAllFoldsForRowTokenAware(pos.row);
lastFoldIndex = -1; // Reset to start at line selection
lastCursorPos = { row: pos.row, column: pos.column };
}
// Level -1: First click always selects current line
if (lastFoldIndex === -1) {
const line = session.getLine(pos.row);
const range = new R(pos.row, 0, pos.row, line.length);
ed.selection.setRange(range, false);
ed.focus();
lastFoldIndex = 0; // Move to first fold next time
return;
}
// Levels 0+: Cycle through folds (innermost to outermost)
if (lastFoldIndex > 0 && lastFoldIndex <= cachedFolds.length) {
const fold = cachedFolds[lastFoldIndex - 1]; // -1 because we start counting from 0
const range = new R(
fold.start, 0,
fold.end, session.getLine(fold.end).length
);
ed.selection.setRange(range, false);
ed.scrollToLine(fold.start, true, true, () => {});
ed.focus();
lastFoldIndex++;
} else {
// Exhausted all folds, wrap back to line selection
const line = session.getLine(pos.row);
const range = new R(pos.row, 0, pos.row, line.length);
ed.selection.setRange(range, false);
ed.focus();
lastFoldIndex = -1; // Reset so next click starts at line again
}
}
/**
* Build all {…} folds that actually ENCLOSE the given row,
* ignoring braces that occur inside strings/comments/regex.
* Uses Ace tokenization for reliability.
*/
function getAllFoldsForRowTokenAware(targetRow) {
const session = ed.getSession();
const lineCount = session.getLength();
const folds = [];
const stack = [];
// Helper: treat only code (not comment/string/regex) as brace-bearing
const isCodeToken = (type) => {
// Token types are like: "paren.lparen", "keyword", "string", "comment", "regex", etc.
// We ignore anything that includes "comment", "string", or "regex".
return !/comment|string|regex/i.test(type);
};
for (let row = 0; row < lineCount; row++) {
// Ace gives us tokens and their values for the row
const tokens = session.getTokens(row); // [{type, value}, ...]
let col = 0;
for (const tok of tokens) {
const { type, value } = tok;
if (isCodeToken(type)) {
// Scan each character to find braces only in code tokens
for (let i = 0; i < value.length; i++) {
const ch = value[i];
if (ch === '{') {
stack.push({ row, col: col + i });
} else if (ch === '}') {
const open = stack.pop();
if (open) {
const startRow = open.row;
const endRow = row;
// Only keep folds that enclose the cursor row
if (startRow <= targetRow && endRow >= targetRow) {
folds.push({ start: startRow, end: endRow });
}
}
}
}
}
// Advance col by this token's width regardless of type
col += value.length;
}
}
// Sort by size (inner first → outer last)
folds.sort((a, b) => (a.end - a.start) - (b.end - b.start));
// De-dup (just in case)
const uniq = [];
for (const f of folds) {
if (!uniq.some(u => u.start === f.start && u.end === f.end)) uniq.push(f);
}
return uniq;
}
// Search functionality
let state={matches:[],idx:-1,markers:[]};
function clear(){
// Clear all markers properly
const session = ed.getSession();
state.markers.forEach(id=>{
try{
session.removeMarker(id);
}catch(e){}
});
state.markers=[];
}
function mark(){
clear();
const session = ed.getSession();
const R=ace.require('ace/range').Range;
state.matches.forEach((m,i)=>{
const r=new R(m.r,m.s,m.r,m.e);
const cls=i===state.idx?'match_marker_current':'match_marker';
const markerId = session.addMarker(r,cls,'text');
state.markers.push(markerId);
});
}
function go(){
if(state.idx<0||!state.matches.length)return;
const m=state.matches[state.idx];
const R=ace.require('ace/range').Range;
const r=new R(m.r,m.s,m.r,m.e);
ed.selection.setRange(r,false);
ed.scrollToLine(m.r,true,true,()=>{});
mark();
document.getElementById('count').textContent=state.matches.length?`${state.idx+1}/${state.matches.length}`:'';
}
function search(){
const q=document.getElementById('query').value.trim();
if(!q){state={matches:[],idx:-1,markers:[]};clear();document.getElementById('count').textContent='';return;}
const rx=new RegExp(q.replace(/[.*+?^${}()|[\]\\]/g,'\\$&'),'gi');
const lines=ed.getSession().getDocument().getAllLines();
const matches=[];
for(let r=0;r<lines.length;r++){
let m;rx.lastIndex=0;
while((m=rx.exec(lines[r]))){
matches.push({r:r,s:m.index,e:m.index+m[0].length});
if(m.index===rx.lastIndex)rx.lastIndex++;
}
}
state.matches=matches;
state.idx=matches.length?0:-1;
go();
}
function next(){
if(!state.matches.length)return;
state.idx=(state.idx+1)%state.matches.length;
go();
}
function prev(){
if(!state.matches.length)return;
state.idx=(state.idx-1+state.matches.length)%state.matches.length;
go();
}
document.getElementById('query').addEventListener('input',search);
document.getElementById('query').addEventListener('keydown',e=>{
if(e.key==='Enter'){
e.preventDefault();
e.shiftKey?prev():next();
}
});
// Close overlay on outside click
document.getElementById('overlay').addEventListener('click', (e) => {
if(e.target.id === 'overlay') closeFindPaste();
});
</script>
</body>
</html>