<?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 = 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 < cachedFolds.length) {
const fold = cachedFolds[lastFoldIndex];
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++;
// After last fold, prepare to wrap back to line
if (lastFoldIndex >= cachedFolds.length) {
lastFoldIndex = -1;
}
} 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 and precise cursor containment.
*/
function getAllFoldsForRowTokenAware(targetRow) {
const session = ed.getSession();
const lineCount = session.getLength();
const stack = [];
const allPairs = [];
// Helper: ignore comment/string/regex tokens for brace detection
const isCodeToken = (type) => !/comment|string|regex/i.test(type);
// Build brace pairs with row+col
for (let row = 0; row < lineCount; row++) {
const tokens = session.getTokens(row);
let col = 0;
for (const tok of tokens) {
const { type, value } = tok;
if (isCodeToken(type)) {
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) {
allPairs.push({
startRow: open.row,
startCol: open.col,
endRow: row,
endCol: col + i
});
}
}
}
}
col += value.length;
}
}
// Cursor precise position
const cursor = ed.getCursorPosition(); // {row, column}
// Strict containment by (row,col):
// - If cursor on start row, must be strictly after '{'
// - If cursor on end row, must be strictly before '}'
// - If between rows, OK
const containsCursor = (p) => {
if (cursor.row < p.startRow || cursor.row > p.endRow) return false;
if (cursor.row === p.startRow && cursor.column <= p.startCol) return false;
if (cursor.row === p.endRow && cursor.column >= p.endCol) return false;
return true;
};
// Filter: keep only folds that contain the cursor, and are real multi-line folds
// (or at least have some room; this avoids "{}" and brace-adjacent noise)
const filtered = allPairs.filter((p) => {
// Must enclose the cursor by position
if (!containsCursor(p)) return false;
// Size sanity: prefer multi-line, but allow same-row only if width > 1
const isMultiLine = p.endRow > p.startRow;
const sameLineWidth = p.endRow === p.startRow ? (p.endCol - p.startCol) : 0;
// Reject tiny/no-content folds
if (!isMultiLine && sameLineWidth <= 1) return false;
if (isMultiLine && (p.endRow - p.startRow) <= 1) {
// For adjacent rows, make sure there's actual content between them
const textBetween =
session.getTextRange({
start: { row: p.startRow, column: p.startCol + 1 },
end: { row: p.endRow, column: p.endCol }
}).trim();
if (!textBetween) return false;
}
return true;
});
// Deduplicate and sort inner → outer by span (rows first, then cols)
const key = (p) => `${p.startRow}:${p.startCol}-${p.endRow}:${p.endCol}`;
const uniqMap = new Map();
for (const p of filtered) uniqMap.set(key(p), p);
const uniq = Array.from(uniqMap.values());
uniq.sort((a, b) => {
const spanA = (a.endRow - a.startRow) || 0.0001; // prefer smaller row spans first
const spanB = (b.endRow - b.startRow) || 0.0001;
if (spanA !== spanB) return spanA - spanB;
// tie-break by column span (smaller first)
const colSpanA = (a.endRow === a.startRow) ? (a.endCol - a.startCol) : 0;
const colSpanB = (b.endRow === b.startRow) ? (b.endCol - b.startCol) : 0;
return colSpanA - colSpanB;
});
// Return in the shape your selectFold expects: {start, end} rows only
// (but they're now guaranteed to be real, enclosing folds)
return uniq.map(p => ({ start: p.startRow, end: p.endRow }));
}
// 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>